Keep non-AI route bootstrap off assistant APIs

This commit is contained in:
rcourtman 2026-04-08 17:35:01 +01:00
parent 1649faa75d
commit 96236994f6
22 changed files with 323 additions and 77 deletions

View file

@ -206,6 +206,14 @@ management, and fleet control surfaces.
entitlements payloads, but they must not cache their own pre-activation
host counts, synthesize a second grandfather floor, or treat install-time
fleet inventory as the authority for commercial continuity.
14. Keep dormant assistant availability on the shared auth/bootstrap contract.
When `internal/api/ai_handlers.go`,
`internal/api/router_routes_auth_security.go`, or
`internal/api/security_status_capabilities.go` change, first-session,
setup, and install routes that do not own assistant or Patrol UI must
consume `/api/security/status.sessionCapabilities.assistantEnabled`
instead of probing `/api/settings/ai` or `/api/ai/*` during ordinary route
bootstrap.
## Forbidden Paths

View file

@ -178,6 +178,14 @@ quickstart-ready installs may enable Patrol directly without opening provider
setup, while activation-required or offline quickstart states must surface one
canonical blocked reason and fall back to activation-or-BYOK guidance instead
of inferring readiness from provider model catalogs or local credit counters.
That same AI/runtime boundary now also owns the server-side assistant
availability fact used by the app shell. `internal/api/ai_handlers.go`,
`internal/api/security_status_capabilities.go`, and
`internal/api/router_routes_auth_security.go` must expose one canonical
`/api/security/status.sessionCapabilities.assistantEnabled` signal for the
closed assistant affordance, so unrelated shells do not probe
`/api/settings/ai` or `/api/ai/sessions` during ordinary route bootstrap just
to decide whether the assistant drawer may be opened.
That same settings/runtime boundary now also governs BYOK first-run setup:
`frontend-modern/src/components/Settings/useAISettingsState.ts` may send only
provider credentials or base URLs when the operator connects a provider, and

View file

@ -477,6 +477,12 @@ as the canonical runtime contract for `demoMode`, `readOnly`,
must therefore defer their first read until that policy has resolved, so
public demos fail closed without probing hidden commercial routes during
bootstrap.
That same `/api/security/status` contract now also owns closed-shell
assistant availability. The response must expose
`sessionCapabilities.assistantEnabled` as the only general-route bootstrap
fact for dormant assistant chrome, so non-AI routes do not probe
`/api/settings/ai` or `/api/ai/*` merely to decide whether the assistant
affordance may be opened.
That same contract split also makes the licensing boundary explicit:
`/api/license/runtime-capabilities` is the public runtime feature contract,
`/api/license/commercial-posture` is the non-billing upgrade/trial posture

View file

@ -135,6 +135,14 @@ agreement, and cloud-specific enforcement rules.
`/settings/system/billing/usage?details=counting-rules` for explanation and
`/settings/system/billing/plan?intent=max_monitored_systems` for upgrade
intent.
24. Keep dormant assistant availability on the shared browser bootstrap
contract. When `frontend-modern/src/App.tsx`,
`frontend-modern/src/useAppRuntimeState.ts`,
`internal/api/router_routes_auth_security.go`, or
`internal/api/security_status_capabilities.go` change, cloud-paid routes
that do not own assistant or Patrol interaction must consume
`/api/security/status.sessionCapabilities.assistantEnabled` instead of
probing `/api/settings/ai` or `/api/ai/*` during ordinary route bootstrap.
## Forbidden Paths

View file

@ -208,6 +208,16 @@ work extends shared components instead of creating new local variants.
labels must resolve to distinct owned destinations or section anchors
instead of presenting two different labels that land on the same
unscoped billing screen.
15. Keep assistant availability bootstrap on the shared app-shell boundary.
`frontend-modern/src/useAppRuntimeState.ts`,
`frontend-modern/src/App.tsx`,
`frontend-modern/src/components/AI/Chat/index.tsx`, and
`frontend-modern/src/hooks/useDashboardActions.ts` must consume the
backend-owned `/api/security/status.sessionCapabilities.assistantEnabled`
fact instead of probing `/api/settings/ai` or `/api/ai/*` during ordinary
route bootstrap. Closed assistant chrome and non-AI settings panels may
not initialize assistant runtime state until an owned assistant or Patrol
surface is actually open.
## Forbidden Paths
@ -1912,3 +1922,15 @@ response-header inference, hostname heuristics, or per-banner demo branching;
the runtime bootstrap, shared session-capability store, and shared banner
hooks stay on one canonical owner so suppression stays coherent across
customer-facing surfaces.
That same shared app-shell boundary now also owns assistant bootstrap silence
on non-AI routes. `frontend-modern/src/useAppRuntimeState.ts`,
`frontend-modern/src/App.tsx`,
`frontend-modern/src/components/AI/Chat/index.tsx`, and
`frontend-modern/src/hooks/useDashboardActions.ts` must treat
`/api/security/status.sessionCapabilities.assistantEnabled` as the only
general-route assistant availability fact, while closed assistant chrome and
non-AI settings panels stay off `/api/settings/ai` and `/api/ai/*` until an
owned assistant or Patrol surface is actually open. The governed browser
proof in `tests/integration/tests/11-first-session.spec.ts` must continue to
assert that plain settings routes render without assistant bootstrap traffic
or console noise.

View file

@ -390,7 +390,13 @@ That same auth/security boundary also owns browser session-capability posture:
`internal/api/security_status_capabilities.go` must expose
`/api/security/status.sessionCapabilities.demoMode` as the backend-owned
public-demo posture signal, and security/privacy consumers must not infer demo
state from response headers, `/api/health`, or hostname heuristics.
state from response headers, `/api/health`, or hostname heuristics. That same
session-capability contract now also carries the closed-shell assistant
availability fact through
`/api/security/status.sessionCapabilities.assistantEnabled`, so general
settings or security surfaces do not probe `/api/settings/ai` or other
assistant endpoints merely to decide whether dormant assistant chrome may be
opened.
That same token-management boundary now also depends on one neutral
app-runtime context owner. `frontend-modern/src/components/Settings/useAPITokenManagerState.ts`
may consume websocket-backed revocation fan-out through

View file

@ -176,6 +176,14 @@ querying, and the operator-facing storage health presentation layer.
backup counts, or recovery-point presence when commercial continuity is
already defined by the canonical monitored-system resolver and activation
persistence.
37. Keep dormant assistant availability on the shared auth/bootstrap contract.
When `frontend-modern/src/App.tsx`, `internal/api/ai_handlers.go`,
`internal/api/router_routes_auth_security.go`, or
`internal/api/security_status_capabilities.go` change, storage and
recovery routes that do not own assistant or Patrol interaction must
consume `/api/security/status.sessionCapabilities.assistantEnabled`
instead of probing `/api/settings/ai` or `/api/ai/*` during ordinary route
bootstrap.
## Forbidden Paths

View file

@ -296,26 +296,8 @@ function App() {
onCloseCommandPalette: () => setCommandPaletteOpen(false),
});
// Check AI settings on mount and setup escape handling
// Setup escape handling for the assistant drawer.
onMount(() => {
// Only check AI settings if already authenticated (not on login screen)
// Otherwise, the 401 response triggers a redirect loop
if (!runtime.needsAuth()) {
import('./api/ai').then(({ AIAPI }) => {
AIAPI.getSettings()
.then((settings) => {
aiChatStore.setEnabled(settings.enabled && settings.configured);
// Initialize chat session sync with server
if (settings.enabled && settings.configured) {
aiChatStore.initSync();
}
})
.catch(() => {
aiChatStore.setEnabled(false);
});
});
}
const handleKeyDown = (e: KeyboardEvent) => {
// Escape to close
if (e.key === 'Escape' && aiChatStore.isOpen) {

View file

@ -40,6 +40,8 @@ describe('App architecture', () => {
expect(appSource).not.toContain('const [organizations, setOrganizations] = createSignal(');
expect(appSource).not.toContain('const [themePreference, setThemePreference] =');
expect(appSource).not.toContain('const [activeOrgID, setActiveOrgID] = createSignal(');
expect(appSource).not.toContain("import('./api/ai')");
expect(appSource).not.toContain('AIAPI.getSettings()');
});
it('keeps authenticated chrome in AppLayout and hosted bootstrap in useAppRuntimeState', () => {
@ -82,6 +84,7 @@ describe('App architecture', () => {
expect(appRuntimeStateSource).toContain('onMount(() => {');
expect(appRuntimeStateSource).toContain('onMount(async () => {');
expect(appRuntimeStateSource).toContain('void loadCommercialPosture();');
expect(appRuntimeStateSource).toContain('aiChatStore.setEnabled(');
expect(appRuntimeStateSource).toContain("eventBus.on('theme_changed', handleRemoteThemeChange);");
expect(appRuntimeStateSource).toContain(
"eventBus.on('websocket_reconnected', handleWebSocketReconnected);",

View file

@ -19,6 +19,7 @@ describe('useAppRuntimeState', () => {
let getOrgIDMock: ReturnType<typeof vi.fn>;
let setOrgIDMock: ReturnType<typeof vi.fn>;
let showToastMock: ReturnType<typeof vi.fn>;
let aiChatSetEnabledMock: ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.resetModules();
@ -67,6 +68,7 @@ describe('useAppRuntimeState', () => {
getOrgIDMock = vi.fn().mockReturnValue('default');
setOrgIDMock = vi.fn();
showToastMock = vi.fn();
aiChatSetEnabledMock = vi.fn();
vi.doMock('@/stores/websocket-global', () => ({
getGlobalWebSocketStore: () => ({
@ -203,6 +205,12 @@ describe('useAppRuntimeState', () => {
loadRuntimeCapabilities: loadLicenseStatusMock,
}));
vi.doMock('@/stores/aiChat', () => ({
aiChatStore: {
setEnabled: aiChatSetEnabledMock,
},
}));
vi.doMock('@/utils/layout', () => ({
layoutStore: {
loadFromServer: vi.fn(),
@ -277,7 +285,7 @@ describe('useAppRuntimeState', () => {
return new Response(
JSON.stringify({
hasAuthentication: true,
sessionCapabilities: { demoMode: true },
sessionCapabilities: { demoMode: true, assistantEnabled: true },
}),
{ status: 200 },
);
@ -296,6 +304,7 @@ describe('useAppRuntimeState', () => {
expect(demoModeModule.demoModeResolved()).toBe(true);
expect(demoModeModule.demoModeEnabled()).toBe(true);
expect(aiChatSetEnabledMock).toHaveBeenCalledWith(true);
dispose();
});

View file

@ -23,6 +23,10 @@ describe('SecurityAPI', () => {
exportProtected: true,
hasAuditLogging: true,
configuredButPendingRestart: false,
sessionCapabilities: {
demoMode: false,
assistantEnabled: true,
},
};
vi.mocked(apiFetchJSON).mockResolvedValueOnce(mockStatus);
@ -31,6 +35,27 @@ describe('SecurityAPI', () => {
expect(apiFetchJSON).toHaveBeenCalledWith('/api/security/status');
expect(result).toEqual(mockStatus);
});
it('preserves assistant availability in session capabilities', async () => {
const mockStatus: SecurityStatus = {
hasAuthentication: true,
apiTokenConfigured: false,
requiresAuth: true,
credentialsEncrypted: true,
exportProtected: true,
hasAuditLogging: false,
configuredButPendingRestart: false,
sessionCapabilities: {
demoMode: false,
assistantEnabled: true,
},
};
vi.mocked(apiFetchJSON).mockResolvedValueOnce(mockStatus);
const result = await SecurityAPI.getStatus();
expect(result.sessionCapabilities?.assistantEnabled).toBe(true);
});
});
describe('listTokens', () => {

View file

@ -286,6 +286,19 @@ describe('AIChat', () => {
renderChat();
expect(screen.queryByText('Pulse Assistant')).not.toBeInTheDocument();
});
it('does not initialize sessions or models while closed', async () => {
mockAiChatStore.isOpenSignal.mockReturnValue(false);
renderChat();
await Promise.resolve();
await Promise.resolve();
expect(mockAIChatAPI.getStatus).not.toHaveBeenCalled();
expect(mockAIChatAPI.listSessions).not.toHaveBeenCalled();
expect(mockAIAPI.getSettings).not.toHaveBeenCalled();
expect(mockAIAPI.getModels).not.toHaveBeenCalled();
});
});
// ── Input & submit ───────────────────────────────────────────────────

View file

@ -246,9 +246,31 @@ export const AIChat: Component<AIChatProps> = (props) => {
}
});
const initializeWhenOpen = async () => {
try {
const status = await AIChatAPI.getStatus();
if (!status.running) {
// AI not running - silently clear dynamic state instead of logging on
// unrelated routes or after the assistant is disabled.
setSessions([]);
setModels([]);
setModelsError('');
return;
}
await refreshSessions();
await loadSettings();
await loadModels();
} catch (error) {
logger.error('[AIChat] Failed to initialize:', error);
}
};
// Refresh models when AI settings change (e.g., new API key added)
createEffect(() => {
const version = aiChatStore.settingsVersionSignal();
if (!isOpen()) {
return;
}
// Skip the initial run (version 0)
if (version > 0) {
loadModels();
@ -290,21 +312,11 @@ export const AIChat: Component<AIChatProps> = (props) => {
return { type: 'thinking', text: 'Thinking...' };
});
// Load sessions on mount
onMount(async () => {
try {
const status = await AIChatAPI.getStatus();
if (!status.running) {
// AI not running - silently return, don't show warning on every page load
// Users who intentionally disabled AI don't need a notification about it
return;
}
await refreshSessions();
await loadSettings();
await loadModels();
} catch (error) {
logger.error('[AIChat] Failed to initialize:', error);
createEffect(() => {
if (!isOpen()) {
return;
}
void initializeWhenOpen();
});
// Click outside handler to close all dropdowns

View file

@ -37,6 +37,7 @@ describe('useDashboardActions', () => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-01T00:00:00Z'));
window.history.replaceState({}, '', '/dashboard');
});
afterEach(() => {
@ -164,4 +165,26 @@ describe('useDashboardActions', () => {
dispose();
});
it('stays idle when the dashboard action surface is not enabled', async () => {
let dispose!: () => void;
let actions!: ReturnType<typeof useDashboardActions>;
createRoot((d) => {
dispose = d;
const [alertsList] = createSignal<Alert[]>([]);
const [enabled] = createSignal(false);
actions = useDashboardActions(alertsList, { enabled });
});
await Promise.resolve();
await Promise.resolve();
expect(vi.mocked(AIAPI.getUnifiedFindings)).not.toHaveBeenCalled();
expect(vi.mocked(AIAPI.getPendingApprovals)).not.toHaveBeenCalled();
expect(actions.pendingApprovals()).toEqual([]);
expect(actions.findingsNeedingAttention()).toEqual([]);
dispose();
});
});

View file

@ -4,6 +4,7 @@ import type { UnifiedFinding } from '@/stores/aiIntelligence';
import { aiIntelligenceStore } from '@/stores/aiIntelligence';
import type { ApprovalRequest } from '@/api/ai';
import { hasFeature } from '@/stores/license';
import { DASHBOARD_PATH } from '@/routing/resourceLinks';
export interface DashboardActions {
pendingApprovals: Accessor<ApprovalRequest[]>;
@ -13,16 +14,26 @@ export interface DashboardActions {
totalActionCount: Accessor<number>;
}
interface UseDashboardActionsOptions {
enabled?: Accessor<boolean>;
}
const APPROVAL_REFRESH_INTERVAL_MS = 30_000;
export function useDashboardActions(alertsList: Accessor<Alert[]>): DashboardActions {
const hasPatrol = () => hasFeature('ai_patrol');
export function useDashboardActions(
alertsList: Accessor<Alert[]>,
options: UseDashboardActionsOptions = {},
): DashboardActions {
const enabled = () =>
options.enabled?.() ??
(typeof window === 'undefined' || window.location.pathname === DASHBOARD_PATH);
const hasPatrol = () => enabled() && hasFeature('ai_patrol');
// Load patrol data on mount when feature is enabled
createEffect(() => {
if (hasPatrol()) {
aiIntelligenceStore.loadFindings();
aiIntelligenceStore.loadPendingApprovals();
void aiIntelligenceStore.loadFindings();
void aiIntelligenceStore.loadPendingApprovals();
}
});
@ -35,7 +46,7 @@ export function useDashboardActions(alertsList: Accessor<Alert[]>): DashboardAct
}
if (hasPatrol()) {
refreshInterval = window.setInterval(() => {
aiIntelligenceStore.loadPendingApprovals();
void aiIntelligenceStore.loadPendingApprovals();
}, APPROVAL_REFRESH_INTERVAL_MS);
}
});

View file

@ -119,6 +119,7 @@ export interface SecurityStatusSettingsCapabilities {
export interface SecurityStatusSessionCapabilities {
demoMode: boolean;
assistantEnabled?: boolean;
}
export interface SecurityStatusPresentationPolicy {

View file

@ -52,6 +52,7 @@ import {
runtimeCapabilitiesLoaded,
loadRuntimeCapabilities,
} from '@/stores/license';
import { aiChatStore } from '@/stores/aiChat';
import { loadCommercialPosture } from '@/stores/licenseCommercial';
import { syncSessionPresentationPolicy } from '@/stores/sessionPresentationPolicy';
import { layoutStore } from '@/utils/layout';
@ -276,6 +277,14 @@ export const useAppRuntimeState = () => {
}
};
const applySecurityStatus = (securityData: SecurityStatus | null) => {
if (securityData) {
syncSessionPresentationPolicy(securityData);
}
setSecurityStatus(securityData);
aiChatStore.setEnabled(securityData?.sessionCapabilities?.assistantEnabled === true);
};
const beginAuthenticatedRuntime = async () => {
setNeedsAuth(false);
await loadOrganizations();
@ -572,6 +581,7 @@ export const useAppRuntimeState = () => {
} catch (clearError) {
logger.warn('[App] Failed to clear stored auth after 401', clearError);
}
aiChatStore.setEnabled(false);
setHasAuth(false);
setNeedsAuth(true);
setIsLoading(false);
@ -583,8 +593,7 @@ export const useAppRuntimeState = () => {
logger.debug('[App] User logged out, showing login page');
if (securityResponse.ok) {
const securityData = (await securityResponse.json()) as SecurityStatus;
syncSessionPresentationPolicy(securityData);
setSecurityStatus(securityData);
applySecurityStatus(securityData);
}
setHasAuth(true);
setNeedsAuth(true);
@ -606,8 +615,7 @@ export const useAppRuntimeState = () => {
ssoLogoutURL?: string;
};
logger.debug('[App] Security status fetched', securityData);
syncSessionPresentationPolicy(securityData);
setSecurityStatus(securityData);
applySecurityStatus(securityData);
const authConfigured = securityData.hasAuthentication || false;
setHasAuth(authConfigured);
@ -664,6 +672,7 @@ export const useAppRuntimeState = () => {
} catch (clearError) {
logger.warn('[App] Failed to clear stored auth after auth check error', clearError);
}
aiChatStore.setEnabled(false);
setHasAuth(false);
setNeedsAuth(true);
} finally {
@ -705,6 +714,7 @@ export const useAppRuntimeState = () => {
keysToRemove.forEach((key) => localStorage.removeItem(key));
sessionStorage.clear();
localStorage.setItem('just_logged_out', 'true');
aiChatStore.setEnabled(false);
if (wsStore()) {
setWsStore(null);

View file

@ -2314,6 +2314,38 @@ func (h *AISettingsHandler) populateQuickstartFields(ctx context.Context, resp *
}
}
// AssistantEnabled reports whether the Pulse Assistant affordance should be
// shown in the authenticated shell without forcing the browser to probe the
// full AI settings API on every route bootstrap.
func (h *AISettingsHandler) AssistantEnabled(ctx context.Context) bool {
if h == nil {
return false
}
settings, err := h.loadAIConfig(ctx)
if err != nil {
return false
}
if settings == nil {
settings = config.NewDefaultAIConfig()
}
enabled := settings.Enabled || mockmode.IsEnabled()
if !enabled {
return false
}
if settings.IsConfigured() || mockmode.IsEnabled() {
return true
}
response := AISettingsResponse{
ConfiguredProviders: settings.GetConfiguredProviders(),
}.NormalizeCollections()
h.populateQuickstartFields(ctx, &response)
return response.QuickstartCreditsAvailable
}
func aiSettingsRequireModelResolution(settings *config.AIConfig) bool {
if settings == nil || !settings.IsConfigured() {
return false

View file

@ -288,7 +288,7 @@ func (r *Router) registerAuthSecurityInstallRoutes() {
"ssoSessionUsername": ssoSessionUsername,
"hideLocalLogin": r.config.HideLocalLogin,
"agentUrl": agentURL,
"sessionCapabilities": r.securityStatusSessionCapabilities(),
"sessionCapabilities": r.securityStatusSessionCapabilities(req.Context()),
"presentationPolicy": r.securityStatusPresentationPolicy(),
"settingsCapabilities": r.securityStatusSettingsCapabilitiesFromSnapshot(authSnapshot),
}

View file

@ -410,6 +410,9 @@ func TestSecurityStatusIncludesDemoModeSessionCapabilities(t *testing.T) {
if !payload.SessionCapabilities.DemoMode {
t.Fatalf("expected sessionCapabilities.demoMode to be true, got %#v", payload.SessionCapabilities)
}
if payload.SessionCapabilities.AssistantEnabled {
t.Fatalf("expected sessionCapabilities.assistantEnabled to default false, got %#v", payload.SessionCapabilities)
}
if !payload.PresentationPolicy.DemoMode ||
!payload.PresentationPolicy.ReadOnly ||
!payload.PresentationPolicy.HideCommercial ||
@ -417,3 +420,39 @@ func TestSecurityStatusIncludesDemoModeSessionCapabilities(t *testing.T) {
t.Fatalf("expected demo presentation policy, got %#v", payload.PresentationPolicy)
}
}
func TestSecurityStatusIncludesAssistantAvailability(t *testing.T) {
t.Setenv("PULSE_DATA_DIR", t.TempDir())
cfg := &config.Config{}
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
persistence := router.aiSettingsHandler.getPersistence(context.Background())
if persistence == nil {
t.Fatal("expected AI persistence for security status test")
}
if err := persistence.SaveAIConfig(config.AIConfig{
Enabled: true,
OpenAIAPIKey: "sk-test",
}); err != nil {
t.Fatalf("SaveAIConfig(): %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for security status, got %d", rec.Code)
}
var payload struct {
SessionCapabilities securityStatusSessionCapabilities `json:"sessionCapabilities"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if !payload.SessionCapabilities.AssistantEnabled {
t.Fatalf("expected sessionCapabilities.assistantEnabled to be true, got %#v", payload.SessionCapabilities)
}
}

View file

@ -1,6 +1,7 @@
package api
import (
"context"
"net/http"
"strings"
@ -26,7 +27,8 @@ type securityStatusSettingsCapabilities struct {
}
type securityStatusSessionCapabilities struct {
DemoMode bool `json:"demoMode"`
DemoMode bool `json:"demoMode"`
AssistantEnabled bool `json:"assistantEnabled"`
}
type securityStatusPresentationPolicy struct {
@ -238,10 +240,15 @@ func (r *Router) securityStatusSettingsCapabilities(req *http.Request) securityS
return r.securityStatusSettingsCapabilitiesFromSnapshot(r.buildSecurityStatusAuthSnapshot(req))
}
func (r *Router) securityStatusSessionCapabilities() securityStatusSessionCapabilities {
func (r *Router) securityStatusSessionCapabilities(ctx context.Context) securityStatusSessionCapabilities {
demoMode := r != nil && r.config != nil && r.config.DemoMode
assistantEnabled := false
if r != nil && r.aiSettingsHandler != nil {
assistantEnabled = r.aiSettingsHandler.AssistantEnabled(ctx)
}
return securityStatusSessionCapabilities{
DemoMode: demoMode,
DemoMode: demoMode,
AssistantEnabled: assistantEnabled,
}
}

View file

@ -115,45 +115,58 @@ test.describe.serial('First-session experience', () => {
] as const;
const consoleErrors: string[] = [];
const aiRequests = trackBrowserRequests(page, /\/api\/ai(\/|$)/);
page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
for (const route of keyRoutes) {
consoleErrors.length = 0;
try {
for (const route of keyRoutes) {
consoleErrors.length = 0;
aiRequests.clear();
await page.goto(route, { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/settings/, { timeout: 10_000 });
await expect(page.locator('#root')).toBeVisible();
await page.goto(route, { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/settings/, { timeout: 10_000 });
await expect(page.locator('#root')).toBeVisible();
// Wait for the panel content to render. Scope to the settings content
// area (everything after the sidebar) to avoid matching sidebar labels.
// Fall back to a page-wide heading if the content area locator doesn't
// match — some layouts render headings differently.
const panelHeading = page.locator('h1, h2, h3, [role="heading"]')
.filter({ hasNotText: 'Settings' })
.first();
await expect(
panelHeading,
`Route ${route} did not render any panel content`,
).toBeVisible({ timeout: 10_000 });
// Wait for the panel content to render. Scope to the settings content
// area (everything after the sidebar) to avoid matching sidebar labels.
// Fall back to a page-wide heading if the content area locator doesn't
// match — some layouts render headings differently.
const panelHeading = page.locator('h1, h2, h3, [role="heading"]')
.filter({ hasNotText: 'Settings' })
.first();
await expect(
panelHeading,
`Route ${route} did not render any panel content`,
).toBeVisible({ timeout: 10_000 });
// Filter out benign console noise.
const realErrors = consoleErrors.filter(
(e) =>
!e.includes('Download the React DevTools') &&
!e.includes('Warning:') &&
!e.includes('favicon') &&
!e.includes('ERR_CONNECTION_REFUSED') &&
!e.includes('net::'),
);
await page.waitForTimeout(500);
expect(
realErrors.length,
`Unexpected console errors on ${route}: ${realErrors.join('; ')}`,
).toBe(0);
expect(
aiRequests.count(),
`Non-AI settings route ${route} should not bootstrap AI endpoints: ${aiRequests.urls().join(', ')}`,
).toBe(0);
// Filter out benign console noise.
const realErrors = consoleErrors.filter(
(e) =>
!e.includes('Download the React DevTools') &&
!e.includes('Warning:') &&
!e.includes('favicon') &&
!e.includes('ERR_CONNECTION_REFUSED') &&
!e.includes('net::'),
);
expect(
realErrors.length,
`Unexpected console errors on ${route}: ${realErrors.join('; ')}`,
).toBe(0);
}
} finally {
aiRequests.stop();
}
});