mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
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:
parent
c4b893e257
commit
54fc259221
15 changed files with 766 additions and 138 deletions
78
.github/workflows/test-e2e.yml
vendored
Normal file
78
.github/workflows/test-e2e.yml
vendored
Normal 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
|
||||||
|
|
||||||
|
|
@ -38,6 +38,24 @@ function getProviderFromModelId(modelId: string): string {
|
||||||
return 'ollama';
|
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
|
// Group models by provider for optgroup rendering
|
||||||
function groupModelsByProvider(models: { id: string; name: string; description?: string }[]): Map<string, { id: string; name: string; description?: string }[]> {
|
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 }[]>();
|
const grouped = new Map<string, { id: string; name: string; description?: string }[]>();
|
||||||
|
|
@ -243,11 +261,39 @@ export const AISettings: Component = () => {
|
||||||
const handleSave = async (event?: Event) => {
|
const handleSave = async (event?: Event) => {
|
||||||
event?.preventDefault();
|
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);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
provider: form.provider,
|
provider: form.provider,
|
||||||
model: form.model.trim(),
|
model: selectedModel,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only include base_url if it's set or if provider is ollama
|
// 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) => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -545,7 +609,8 @@ export const AISettings: Component = () => {
|
||||||
<Show when={!form.model || !availableModels().some(m => m.id === form.model)}>
|
<Show when={!form.model || !availableModels().some(m => m.id === form.model)}>
|
||||||
<option value={form.model}>{form.model || 'Select a model...'}</option>
|
<option value={form.model}>{form.model || 'Select a model...'}</option>
|
||||||
</Show>
|
</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]) => (
|
{([provider, models]) => (
|
||||||
<optgroup label={PROVIDER_DISPLAY_NAMES[provider] || provider}>
|
<optgroup label={PROVIDER_DISPLAY_NAMES[provider] || provider}>
|
||||||
<For each={models}>
|
<For each={models}>
|
||||||
|
|
@ -558,8 +623,32 @@ export const AISettings: Component = () => {
|
||||||
</optgroup>
|
</optgroup>
|
||||||
)}
|
)}
|
||||||
</For>
|
</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>
|
</select>
|
||||||
</Show>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Advanced Model Selection - Collapsible */}
|
{/* Advanced Model Selection - Collapsible */}
|
||||||
|
|
@ -1017,11 +1106,15 @@ export const AISettings: Component = () => {
|
||||||
<Show when={showPatrolSettings()}>
|
<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">
|
<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 */}
|
{/* Patrol Interval - Compact */}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
<div class="flex items-center gap-3">
|
<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>
|
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 w-32 flex-shrink-0">Patrol Interval</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
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"
|
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}
|
value={form.patrolIntervalMinutes}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
const value = parseInt(e.currentTarget.value, 10);
|
const value = parseInt(e.currentTarget.value, 10);
|
||||||
|
|
@ -1032,7 +1125,11 @@ export const AISettings: Component = () => {
|
||||||
step={15}
|
step={15}
|
||||||
disabled={saving()}
|
disabled={saving()}
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-gray-500">min (0 = disabled)</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Alert Analysis Toggle - Compact */}
|
{/* Alert Analysis Toggle - Compact */}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
data-testid="updates-auto-check-toggle"
|
||||||
checked={props.autoUpdateEnabled()}
|
checked={props.autoUpdateEnabled()}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
props.setAutoUpdateEnabled(e.currentTarget.checked);
|
props.setAutoUpdateEnabled(e.currentTarget.checked);
|
||||||
|
|
|
||||||
|
|
@ -410,14 +410,26 @@ func (d *Detector) saveToDisk() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
d.mu.RLock()
|
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 {
|
data := struct {
|
||||||
Events []Event `json:"events"`
|
Events []Event `json:"events"`
|
||||||
Correlations map[string]*Correlation `json:"correlations"`
|
Correlations map[string]*Correlation `json:"correlations"`
|
||||||
}{
|
}{
|
||||||
Events: d.events,
|
Events: eventsSnapshot,
|
||||||
Correlations: d.correlations,
|
Correlations: correlationsSnapshot,
|
||||||
}
|
}
|
||||||
d.mu.RUnlock()
|
|
||||||
|
|
||||||
jsonData, err := json.MarshalIndent(data, "", " ")
|
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -675,16 +675,57 @@ func (s *Service) LoadConfig() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// 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().
|
log.Warn().
|
||||||
Err(err).
|
Err(err).
|
||||||
Str("selected_model", selectedModel).
|
Str("selected_model", selectedModel).
|
||||||
Str("selected_provider", selectedProvider).
|
Str("selected_provider", selectedProvider).
|
||||||
Strs("configured_providers", cfg.GetConfiguredProviders()).
|
Strs("configured_providers", cfg.GetConfiguredProviders()).
|
||||||
Msg("AI enabled but selected provider is not configured; check API keys or model selection")
|
Msg("AI enabled but no providers configured")
|
||||||
s.provider = nil
|
s.provider = nil
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
s.provider = providerClient
|
s.provider = providerClient
|
||||||
log.Info().
|
log.Info().
|
||||||
|
|
|
||||||
|
|
@ -106,37 +106,15 @@ npm run test:report
|
||||||
|
|
||||||
## Test Scenarios
|
## Test Scenarios
|
||||||
|
|
||||||
### 1. Happy Path (`01-happy-path.spec.ts`)
|
### 1. Diagnostic Smoke Test (`00-diagnostic.spec.ts`)
|
||||||
- Valid checksums, successful update flow
|
- Ensures the containerized stack boots and the UI renders.
|
||||||
- Tests complete update from UI to backend
|
|
||||||
- Verifies modal appears exactly once
|
|
||||||
|
|
||||||
### 2. Bad Checksums (`02-bad-checksums.spec.ts`)
|
### 2. Core E2E Flows (`01-core-e2e.spec.ts`)
|
||||||
- Server rejects update due to invalid checksums
|
- First-run setup wizard (fresh instance)
|
||||||
- UI shows error **once** (not twice)
|
- Login/logout + authenticated state
|
||||||
- Error messages are user-friendly
|
- Alerts thresholds create/delete
|
||||||
|
- Settings persistence across refresh
|
||||||
### 3. Rate Limiting (`03-rate-limiting.spec.ts`)
|
- Add/delete a Proxmox node (test-only)
|
||||||
- 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
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
## Architecture
|
||||||
|
|
||||||
|
|
@ -14,37 +14,52 @@ End-to-end tests for the Pulse update flow, validating the entire path from UI t
|
||||||
|
|
||||||
## Test Scenarios
|
## Test Scenarios
|
||||||
|
|
||||||
> **Note:** The comprehensive Playwright update specs were removed on 2025‑11‑12 after repeated
|
- `tests/00-diagnostic.spec.ts` — smoke test that the stack boots and the UI renders.
|
||||||
> release-blocking flakes. We now rely on:
|
- `tests/01-core-e2e.spec.ts` — critical UI flows:
|
||||||
>
|
- Bootstrap setup wizard (fresh instance)
|
||||||
> 1. `tests/00-diagnostic.spec.ts` — ensures the containerized stack boots and the login page renders.
|
- Login + authenticated state
|
||||||
> 2. `tests/integration/api/update_flow_test.go` — drives the `/api/updates/*` endpoints directly to
|
- Alerts thresholds create/delete
|
||||||
> verify the backend can discover, plan, apply, and complete an update.
|
- Settings persistence across refresh
|
||||||
>
|
- Add/delete a Proxmox node (test-only)
|
||||||
> Reintroduce full UI coverage once we have deterministic fixtures and selectors for the update flow.
|
|
||||||
|
|
||||||
## Running Tests
|
## Running Tests
|
||||||
|
|
||||||
### Local Development
|
### Local Development (Docker compose stack)
|
||||||
```bash
|
```bash
|
||||||
# Start test environment
|
|
||||||
cd tests/integration
|
cd tests/integration
|
||||||
docker-compose up -d
|
./scripts/setup.sh # one-time (installs deps + builds docker images)
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
# Run diagnostic Playwright test
|
The docker-compose stack seeds a deterministic bootstrap token for first-run setup:
|
||||||
npx playwright test tests/00-diagnostic.spec.ts
|
- 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
|
Credentials used by the E2E suite can be overridden:
|
||||||
UPDATE_API_BASE_URL=http://localhost:7655 go test ./tests/integration/api -run TestUpdateFlowIntegration
|
- `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
|
### Run Against An Existing Pulse Instance
|
||||||
docker-compose down -v
|
```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
|
### 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:
|
The mock GitHub server (`mock-github-server/`) provides controllable responses:
|
||||||
- `/api/releases` - List all releases
|
- `/api/releases` - List all releases
|
||||||
|
|
@ -60,7 +75,5 @@ Response behavior can be controlled via environment variables:
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
- ✅ Tests run in CI on every PR touching update code
|
- ✅ Core E2E flows pass reliably in CI
|
||||||
- ✅ All scenarios pass reliably
|
- ✅ Update flow remains covered via API integration test + smoke UI check
|
||||||
- ✅ Tests catch checksum validation issues automatically
|
|
||||||
- ✅ Frontend UX regressions are blocked
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,24 @@
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
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 API server for controlled testing
|
||||||
mock-github:
|
mock-github:
|
||||||
image: pulse-mock-github:test
|
image: pulse-mock-github:test
|
||||||
container_name: pulse-mock-github
|
container_name: pulse-mock-github
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "${PULSE_E2E_MOCK_GITHUB_PORT:-8080}:8080"
|
||||||
environment:
|
environment:
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
- MOCK_BASE_URL=http://mock-github:8080
|
- MOCK_BASE_URL=http://mock-github:8080
|
||||||
|
|
@ -29,7 +41,7 @@ services:
|
||||||
image: pulse:test
|
image: pulse:test
|
||||||
container_name: pulse-test-server
|
container_name: pulse-test-server
|
||||||
ports:
|
ports:
|
||||||
- "7655:7655"
|
- "${PULSE_E2E_PORT:-7655}:7655"
|
||||||
environment:
|
environment:
|
||||||
- TZ=UTC
|
- TZ=UTC
|
||||||
# Point to mock GitHub server
|
# Point to mock GitHub server
|
||||||
|
|
@ -42,12 +54,11 @@ services:
|
||||||
- PULSE_MOCK_MODE=true
|
- PULSE_MOCK_MODE=true
|
||||||
- PULSE_ALLOW_DOCKER_UPDATES=true
|
- PULSE_ALLOW_DOCKER_UPDATES=true
|
||||||
- PULSE_UPDATE_STAGE_DELAY_MS=250
|
- PULSE_UPDATE_STAGE_DELAY_MS=250
|
||||||
# Pre-configure authentication to bypass first-run setup
|
|
||||||
- PULSE_AUTH_USER=admin
|
|
||||||
- PULSE_AUTH_PASS=admin
|
|
||||||
volumes:
|
volumes:
|
||||||
- test-data:/data
|
- test-data:/data
|
||||||
depends_on:
|
depends_on:
|
||||||
|
seed-bootstrap-token:
|
||||||
|
condition: service_completed_successfully
|
||||||
mock-github:
|
mock-github:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "pulse-integration-tests",
|
"name": "pulse-integration-tests",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Integration tests for Pulse update flow",
|
"description": "Integration tests for Pulse (Playwright E2E)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
|
|
@ -13,8 +13,8 @@
|
||||||
"docker:down": "docker-compose -f docker-compose.test.yml down -v",
|
"docker:down": "docker-compose -f docker-compose.test.yml down -v",
|
||||||
"docker:logs": "docker-compose -f docker-compose.test.yml logs -f",
|
"docker:logs": "docker-compose -f docker-compose.test.yml logs -f",
|
||||||
"docker:rebuild": "docker-compose -f docker-compose.test.yml up -d --build",
|
"docker:rebuild": "docker-compose -f docker-compose.test.yml up -d --build",
|
||||||
"pretest": "npm run docker:up && sleep 10",
|
"pretest": "node ./scripts/pretest.mjs",
|
||||||
"posttest": "npm run docker:down"
|
"posttest": "node ./scripts/posttest.mjs"
|
||||||
},
|
},
|
||||||
"keywords": ["pulse", "integration", "e2e", "playwright"],
|
"keywords": ["pulse", "integration", "e2e", "playwright"],
|
||||||
"author": "rcourtman",
|
"author": "rcourtman",
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,12 @@ export default defineConfig({
|
||||||
/* Shared settings for all projects */
|
/* Shared settings for all projects */
|
||||||
use: {
|
use: {
|
||||||
/* Base URL for all tests */
|
/* 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 */
|
/* Collect trace when retrying the failed test */
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
|
|
@ -59,8 +64,6 @@ export default defineConfig({
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
use: {
|
use: {
|
||||||
...devices['Desktop Chrome'],
|
...devices['Desktop Chrome'],
|
||||||
// Use headless mode in CI
|
|
||||||
headless: !!process.env.CI,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
44
tests/integration/scripts/posttest.mjs
Normal file
44
tests/integration/scripts/posttest.mjs
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
70
tests/integration/scripts/pretest.mjs
Normal file
70
tests/integration/scripts/pretest.mjs
Normal 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`);
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
#
|
#
|
||||||
# Run update integration tests with different configurations
|
# Run Pulse integration tests with different suites
|
||||||
# Usage: ./run-tests.sh [test-suite]
|
# Usage: ./run-tests.sh [suite]
|
||||||
# test-suite: all, happy, checksums, rate-limit, network, stale, frontend
|
# suite: all, core, diagnostic, updates-api
|
||||||
#
|
#
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
@ -10,7 +10,7 @@ set -e
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
TEST_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
TEST_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
TEST_SUITE="${1:-all}"
|
SUITE="${1:-all}"
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
|
|
@ -25,10 +25,10 @@ echo ""
|
||||||
|
|
||||||
cd "$TEST_ROOT"
|
cd "$TEST_ROOT"
|
||||||
|
|
||||||
# Function to run test with specific config
|
# Function to run suite with specific mock config
|
||||||
run_test() {
|
run_suite() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
local file="$2"
|
local suite="$2"
|
||||||
local checksum_error="${3:-false}"
|
local checksum_error="${3:-false}"
|
||||||
local network_error="${4:-false}"
|
local network_error="${4:-false}"
|
||||||
local rate_limit="${5:-false}"
|
local rate_limit="${5:-false}"
|
||||||
|
|
@ -50,7 +50,12 @@ run_test() {
|
||||||
|
|
||||||
# Wait for services
|
# Wait for services
|
||||||
echo "Waiting for services to be ready..."
|
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
|
# Check if services are healthy
|
||||||
if ! docker-compose -f docker-compose.test.yml ps | grep -q "Up"; then
|
if ! docker-compose -f docker-compose.test.yml ps | grep -q "Up"; then
|
||||||
|
|
@ -62,12 +67,30 @@ run_test() {
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
echo "Running 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}"
|
echo -e "${GREEN}✅ $name passed${NC}"
|
||||||
TEST_RESULT=0
|
|
||||||
else
|
else
|
||||||
echo -e "${RED}❌ $name failed${NC}"
|
echo -e "${RED}❌ $name failed${NC}"
|
||||||
TEST_RESULT=1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
|
|
@ -80,45 +103,29 @@ run_test() {
|
||||||
# Run specific test suite or all tests
|
# Run specific test suite or all tests
|
||||||
FAILED_TESTS=()
|
FAILED_TESTS=()
|
||||||
|
|
||||||
case "$TEST_SUITE" in
|
case "$SUITE" in
|
||||||
all)
|
all)
|
||||||
echo "Running all test suites..."
|
echo "Running all suites..."
|
||||||
|
run_suite "Diagnostic Smoke" "diagnostic" || FAILED_TESTS+=("Diagnostic Smoke")
|
||||||
run_test "Happy Path" "tests/01-happy-path.spec.ts" || FAILED_TESTS+=("Happy Path")
|
run_suite "Core E2E" "core" || FAILED_TESTS+=("Core E2E")
|
||||||
run_test "Bad Checksums" "tests/02-bad-checksums.spec.ts" "true" || FAILED_TESTS+=("Bad Checksums")
|
run_suite "Update API Integration" "updates-api" || FAILED_TESTS+=("Update API Integration")
|
||||||
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")
|
|
||||||
;;
|
;;
|
||||||
|
|
||||||
happy)
|
diagnostic)
|
||||||
run_test "Happy Path" "tests/01-happy-path.spec.ts" || FAILED_TESTS+=("Happy Path")
|
run_suite "Diagnostic Smoke" "diagnostic" || FAILED_TESTS+=("Diagnostic Smoke")
|
||||||
;;
|
;;
|
||||||
|
|
||||||
checksums)
|
core)
|
||||||
run_test "Bad Checksums" "tests/02-bad-checksums.spec.ts" "true" || FAILED_TESTS+=("Bad Checksums")
|
run_suite "Core E2E" "core" || FAILED_TESTS+=("Core E2E")
|
||||||
;;
|
;;
|
||||||
|
|
||||||
rate-limit)
|
updates-api)
|
||||||
run_test "Rate Limiting" "tests/03-rate-limiting.spec.ts" "false" "false" "true" || FAILED_TESTS+=("Rate Limiting")
|
run_suite "Update API Integration" "updates-api" || FAILED_TESTS+=("Update API Integration")
|
||||||
;;
|
|
||||||
|
|
||||||
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")
|
|
||||||
;;
|
;;
|
||||||
|
|
||||||
*)
|
*)
|
||||||
echo "Unknown test suite: $TEST_SUITE"
|
echo "Unknown suite: $SUITE"
|
||||||
echo "Available suites: all, happy, checksums, rate-limit, network, stale, frontend"
|
echo "Available suites: all, diagnostic, core, updates-api"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
|
||||||
158
tests/integration/tests/01-core-e2e.spec.ts
Normal file
158
tests/integration/tests/01-core-e2e.spec.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -12,6 +12,79 @@ export const ADMIN_CREDENTIALS = {
|
||||||
password: 'admin',
|
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
|
* Login as admin user
|
||||||
*/
|
*/
|
||||||
|
|
@ -26,6 +99,48 @@ export async function loginAsAdmin(page: Page) {
|
||||||
await page.waitForURL(/\/(dashboard|nodes|proxmox)/);
|
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
|
* Navigate to settings page
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue