mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01: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:
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';
|
||||
}
|
||||
|
||||
// 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 */}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 2025‑11‑12 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
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
|
||||
#
|
||||
# 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
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user