diff --git a/docs/API_CLI_REFERENCE.md b/docs/API_CLI_REFERENCE.md index ef257209b..6b0dcc4d3 100755 --- a/docs/API_CLI_REFERENCE.md +++ b/docs/API_CLI_REFERENCE.md @@ -529,6 +529,29 @@ stella doctor suggest "gateway returns 404 on known route" --json ## stella advisoryai index rebuild +### Local repo build and use of the Stella CLI + +When working from this repository, do not assume `stella` is already installed on `PATH`. +Use one of these local workflows first: + +```bash +# Run the CLI directly from source +dotnet run --project "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -- advisoryai index rebuild --json + +# Publish a reusable local binary +dotnet publish "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -c Release -o ".artifacts/stella-cli" + +# Windows +.artifacts/stella-cli/StellaOps.Cli.exe advisoryai index rebuild --json + +# Linux/macOS +./.artifacts/stella-cli/StellaOps.Cli advisoryai index rebuild --json +``` + +Related docs: +- CLI quickstart: `docs/modules/cli/guides/quickstart.md` +- AdvisoryAI live test setup: `src/AdvisoryAI/__Tests/INFRASTRUCTURE.md` + Rebuild the AdvisoryAI deterministic knowledge index from local markdown, OpenAPI specs, and Doctor metadata. ### Synopsis diff --git a/docs/modules/advisory-ai/knowledge-search.md b/docs/modules/advisory-ai/knowledge-search.md index 2f68595a6..16dba4f24 100644 --- a/docs/modules/advisory-ai/knowledge-search.md +++ b/docs/modules/advisory-ai/knowledge-search.md @@ -312,15 +312,52 @@ docker compose -f devops/compose/docker-compose.advisoryai-knowledge-test.yml up # Wait for health check docker compose -f devops/compose/docker-compose.advisoryai-knowledge-test.yml ps -# Prepare sources and rebuild index -stella advisoryai sources prepare --json -stella advisoryai index rebuild --json +# Start the local AdvisoryAI service against that database +export ADVISORYAI__AdvisoryAI__KnowledgeSearch__ConnectionString="Host=localhost;Port=55432;Database=advisoryai_knowledge_test;Username=stellaops_knowledge;Password=stellaops_knowledge" +export ADVISORYAI__AdvisoryAI__KnowledgeSearch__RepositoryRoot="$(pwd)" +dotnet run --project "src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/StellaOps.AdvisoryAI.WebService.csproj" --no-launch-profile + +# In a second shell, rebuild the live corpus in the required order +dotnet run --project "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -- advisoryai sources prepare --json +dotnet run --project "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -- advisoryai index rebuild --json +curl -X POST http://127.0.0.1:10451/v1/search/index/rebuild \ + -H "X-StellaOps-Scopes: advisory-ai:admin" \ + -H "X-StellaOps-Tenant: test-tenant" # Run tests with the Live category (requires database) dotnet test "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" \ --filter "Category=Live" -v normal ``` +### CLI setup in a source checkout + +Do not assume `stella` is already installed on the machine running local AdvisoryAI work. +From a repository checkout, either run the CLI through `dotnet run` or publish a local binary first. + +Quick references: +- Build/install note and command reference: `docs/API_CLI_REFERENCE.md` +- CLI quickstart: `docs/modules/cli/guides/quickstart.md` + +Local examples: + +```bash +# Run directly from source without installing to PATH +dotnet run --project "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -- advisoryai sources prepare --json + +# Publish a reusable local binary +dotnet publish "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -c Release -o ".artifacts/stella-cli" + +# Windows +.artifacts/stella-cli/StellaOps.Cli.exe advisoryai index rebuild --json + +# Linux/macOS +./.artifacts/stella-cli/StellaOps.Cli advisoryai index rebuild --json +``` + +If the CLI is not built yet, the equivalent HTTP endpoints are: +- `POST /v1/advisory-ai/index/rebuild` for the docs/OpenAPI/Doctor corpus +- `POST /v1/search/index/rebuild` for unified overlay domains + Or use the full CI testing stack: ```bash docker compose -f devops/compose/docker-compose.testing.yml --profile ci up -d diff --git a/docs/modules/cli/guides/quickstart.md b/docs/modules/cli/guides/quickstart.md index f00a86ef6..c22919c8e 100644 --- a/docs/modules/cli/guides/quickstart.md +++ b/docs/modules/cli/guides/quickstart.md @@ -20,6 +20,29 @@ ### Installation +#### Option 0: Source Checkout (local development) + +If you are working from this repository checkout, do not assume `stella` is already installed on `PATH`. +Build or run the CLI from source first. + +Quick references: +- `docs/API_CLI_REFERENCE.md` +- `src/AdvisoryAI/__Tests/INFRASTRUCTURE.md` + +```bash +# Run directly from source +dotnet run --project "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -- --help + +# Publish a reusable local binary +dotnet publish "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -c Release -o ".artifacts/stella-cli" + +# Windows +.artifacts/stella-cli/StellaOps.Cli.exe advisoryai index rebuild --json + +# Linux/macOS +./.artifacts/stella-cli/StellaOps.Cli advisoryai index rebuild --json +``` + #### Option 1: .NET Tool (Recommended) ```bash diff --git a/src/AdvisoryAI/__Tests/INFRASTRUCTURE.md b/src/AdvisoryAI/__Tests/INFRASTRUCTURE.md index 367757c7d..2c67938ae 100644 --- a/src/AdvisoryAI/__Tests/INFRASTRUCTURE.md +++ b/src/AdvisoryAI/__Tests/INFRASTRUCTURE.md @@ -123,14 +123,61 @@ CREATE EXTENSION IF NOT EXISTS vector; -- optional; enables pgvector Migrations run automatically when the service starts (`EnsureSchemaAsync()`). Or run them manually via the service: ```bash -# Configure connection string and rebuild the index (runs migrations + full index rebuild) -export AdvisoryAI__KnowledgeSearch__ConnectionString="Host=localhost;Port=55432;Database=advisoryai_knowledge_test;Username=stellaops_knowledge;Password=stellaops_knowledge" +# Configure connection string for the local AdvisoryAI WebService +export ADVISORYAI__AdvisoryAI__KnowledgeSearch__ConnectionString="Host=localhost;Port=55432;Database=advisoryai_knowledge_test;Username=stellaops_knowledge;Password=stellaops_knowledge" +export ADVISORYAI__AdvisoryAI__KnowledgeSearch__RepositoryRoot="$(pwd)" +``` -# Using CLI -stella advisoryai index rebuild --json +#### CLI availability in a source checkout -# Or via HTTP (service must be running) -curl -X POST https://localhost:10450/v1/advisory-ai/index/rebuild \ +Do not assume `stella` already exists on `PATH` in a local repo checkout. +Build or run the CLI from source first, or use the HTTP endpoints directly. + +Quick links: +- Local CLI build and usage note: `docs/API_CLI_REFERENCE.md` +- CLI quickstart: `docs/modules/cli/guides/quickstart.md` + +Build or publish the CLI from this repository: + +```bash +# One-shot invocation without installing to PATH +dotnet run --project "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -- advisoryai sources prepare --json + +# Publish a reusable local binary +dotnet publish "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -c Release -o ".artifacts/stella-cli" + +# Windows +.artifacts/stella-cli/StellaOps.Cli.exe advisoryai index rebuild --json + +# Linux/macOS +./.artifacts/stella-cli/StellaOps.Cli advisoryai index rebuild --json +``` + +#### Recommended live rebuild order + +For live search and Playwright suggestion tests, rebuild both indexes in this order: + +```bash +# 1. Knowledge corpus: docs + OpenAPI + Doctor checks +dotnet run --project "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -- advisoryai sources prepare --json +dotnet run --project "src/Cli/StellaOps.Cli/StellaOps.Cli.csproj" -- advisoryai index rebuild --json + +# 2. Unified domain overlays: platform, graph, scanner, timeline, opsmemory +curl -X POST http://127.0.0.1:10451/v1/search/index/rebuild \ + -H "X-StellaOps-Scopes: advisory-ai:admin" \ + -H "X-StellaOps-Tenant: test-tenant" +``` + +Or use HTTP only when the CLI is not built yet: + +```bash +# 1. Knowledge corpus rebuild +curl -X POST http://127.0.0.1:10451/v1/advisory-ai/index/rebuild \ + -H "X-StellaOps-Scopes: advisory-ai:admin" \ + -H "X-StellaOps-Tenant: test-tenant" + +# 2. Unified overlay rebuild +curl -X POST http://127.0.0.1:10451/v1/search/index/rebuild \ -H "X-StellaOps-Scopes: advisory-ai:admin" \ -H "X-StellaOps-Tenant: test-tenant" ``` diff --git a/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs b/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs index de69d416e..693d719c7 100644 --- a/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs +++ b/src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs @@ -24023,7 +24023,7 @@ stella policy test {policyName}.stella bool verbose, CancellationToken cancellationToken) { - var client = services.GetRequiredService(); + var client = services.GetRequiredService(); var request = new SourceListRequest { @@ -24082,7 +24082,7 @@ stella policy test {policyName}.stella bool verbose, CancellationToken cancellationToken) { - var client = services.GetRequiredService(); + var client = services.GetRequiredService(); try { @@ -24380,7 +24380,7 @@ stella policy test {policyName}.stella bool verbose, CancellationToken cancellationToken) { - var client = services.GetRequiredService(); + var client = services.GetRequiredService(); var request = new SourceTestRequest { @@ -24466,7 +24466,7 @@ stella policy test {policyName}.stella bool verbose, CancellationToken cancellationToken) { - var client = services.GetRequiredService(); + var client = services.GetRequiredService(); var request = new SourcePauseRequest { @@ -24546,7 +24546,7 @@ stella policy test {policyName}.stella bool verbose, CancellationToken cancellationToken) { - var client = services.GetRequiredService(); + var client = services.GetRequiredService(); var request = new SourceResumeRequest { @@ -24629,7 +24629,7 @@ stella policy test {policyName}.stella bool verbose, CancellationToken cancellationToken) { - var client = services.GetRequiredService(); + var client = services.GetRequiredService(); var request = new BackfillRequest { @@ -24741,7 +24741,7 @@ stella policy test {policyName}.stella bool verbose, CancellationToken cancellationToken) { - var client = services.GetRequiredService(); + var client = services.GetRequiredService(); try { @@ -24848,7 +24848,7 @@ stella policy test {policyName}.stella bool verbose, CancellationToken cancellationToken) { - var client = services.GetRequiredService(); + var client = services.GetRequiredService(); var request = new BackfillListRequest { @@ -24934,7 +24934,7 @@ stella policy test {policyName}.stella bool verbose, CancellationToken cancellationToken) { - var client = services.GetRequiredService(); + var client = services.GetRequiredService(); var request = new BackfillCancelRequest { @@ -25000,7 +25000,7 @@ stella policy test {policyName}.stella bool verbose, CancellationToken cancellationToken) { - var client = services.GetRequiredService(); + var client = services.GetRequiredService(); var request = new QuotaGetRequest { @@ -25100,7 +25100,7 @@ stella policy test {policyName}.stella bool verbose, CancellationToken cancellationToken) { - var client = services.GetRequiredService(); + var client = services.GetRequiredService(); var request = new QuotaSetRequest { @@ -25172,7 +25172,7 @@ stella policy test {policyName}.stella bool verbose, CancellationToken cancellationToken) { - var client = services.GetRequiredService(); + var client = services.GetRequiredService(); var request = new QuotaResetRequest { diff --git a/src/Cli/StellaOps.Cli/Program.cs b/src/Cli/StellaOps.Cli/Program.cs index 25f23ccaf..42bcb3ba7 100644 --- a/src/Cli/StellaOps.Cli/Program.cs +++ b/src/Cli/StellaOps.Cli/Program.cs @@ -281,7 +281,7 @@ internal static class Program }).AddEgressPolicyGuard("stellaops-cli", "exceptions-api"); // CLI-ORCH-32-001: Orchestrator client for source/job management - services.AddHttpClient(client => + services.AddHttpClient(client => { client.Timeout = TimeSpan.FromSeconds(60); if (!string.IsNullOrWhiteSpace(options.BackendUrl) && diff --git a/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts b/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts index 45eea0691..d41d931c6 100644 --- a/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.ts @@ -1962,6 +1962,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { }); this.query.set(query); this.searchTerms$.next(query.trim()); + this.keepSearchSurfaceOpen(); } applyExampleQuery(example: string): void { @@ -1971,6 +1972,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { }); this.query.set(example); this.searchTerms$.next(example.trim()); + this.keepSearchSurfaceOpen(); } applyQuestionQuery(query: string, source: 'common' | 'answer' | 'clarify'): void { @@ -1985,6 +1987,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { }); this.query.set(query); this.searchTerms$.next(query.trim()); + this.keepSearchSurfaceOpen(); } applyAnswerNextSearch(query: string): void { @@ -1994,6 +1997,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { }); this.query.set(query); this.searchTerms$.next(query.trim()); + this.keepSearchSurfaceOpen(); } applySuggestion(text: string): void { @@ -2004,6 +2008,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { this.query.set(text); this.saveRecentSearch(text); this.searchTerms$.next(text.trim()); + this.keepSearchSurfaceOpen(); } applyRefinement(refinement: SearchRefinement): void { @@ -2014,6 +2019,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { this.query.set(refinement.text); this.saveRecentSearch(refinement.text); this.searchTerms$.next(refinement.text.trim()); + this.keepSearchSurfaceOpen(); } navigateQuickAction(route: string): void { @@ -2352,6 +2358,16 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { }); } + private keepSearchSurfaceOpen(): void { + if (this.blurHideHandle) { + clearTimeout(this.blurHideHandle); + this.blurHideHandle = null; + } + + this.isFocused.set(true); + setTimeout(() => this.searchInputRef?.nativeElement?.focus(), 0); + } + private scoreSuggestionForMode( suggestion: { kind?: 'page' | 'recent' | 'strategy'; diff --git a/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts b/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts index 712d191b7..1572f34ff 100644 --- a/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts +++ b/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts @@ -139,6 +139,29 @@ test.describe('Unified Search - Contextual Suggestions', () => { ); }); + test('clicking a contextual chip keeps the search surface open and renders results', async ({ page }) => { + await page.route('**/search/query**', (route) => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(criticalFindingsResponse), + })); + + await page.goto('/security/triage'); + await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 }); + + await page.locator('app-global-search input[type="text"]').focus(); + await waitForResults(page); + await page.locator('.search__suggestions .search__chip', { + hasText: /critical findings/i, + }).first().click(); + + await expect(page.locator('app-global-search input[type="text"]')).toHaveValue('critical findings'); + await waitForResults(page); + await waitForEntityCards(page, 1); + await expect(page.locator('.search__cards')).toBeVisible(); + await expect(page.locator('app-entity-card').first()).toContainText(/cve-2024-21626/i); + }); + test('chat search-for-more emits ambient lastAction and route context in follow-up search requests', async ({ page, }) => { diff --git a/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.live.e2e.spec.ts b/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.live.e2e.spec.ts new file mode 100644 index 000000000..12f3be762 --- /dev/null +++ b/src/Web/StellaOps.Web/tests/e2e/unified-search-contextual-suggestions.live.e2e.spec.ts @@ -0,0 +1,355 @@ +import { expect, test, type Page } from '@playwright/test'; + +import { policyAuthorSession } from '../../src/app/testing'; +import { waitForEntityCards, waitForResults } from './unified-search-fixtures'; + +const liveSearchBaseUrl = process.env['LIVE_ADVISORYAI_SEARCH_BASE_URL']?.trim() ?? ''; +const liveTenant = process.env['LIVE_ADVISORYAI_TENANT']?.trim() || 'test-tenant'; +const liveScopes = process.env['LIVE_ADVISORYAI_SCOPES']?.trim() + || 'advisory-ai:view advisory-ai:operate advisory-ai:admin'; + +const mockConfig = { + authority: { + issuer: 'https://authority.local', + clientId: 'stella-ops-ui', + authorizeEndpoint: 'https://authority.local/connect/authorize', + tokenEndpoint: 'https://authority.local/connect/token', + logoutEndpoint: 'https://authority.local/connect/logout', + redirectUri: 'http://127.0.0.1:4400/auth/callback', + postLogoutRedirectUri: 'http://127.0.0.1:4400/', + scope: 'openid profile email ui.read doctor:read advisory-ai:view advisory-ai:operate advisory-ai:admin', + audience: 'https://doctor.local', + }, + apiBaseUrls: { + authority: 'https://authority.local', + doctor: 'https://doctor.local', + gateway: 'https://gateway.local', + }, + quickstartMode: true, + setup: 'complete', +}; + +const oidcConfig = { + issuer: mockConfig.authority.issuer, + authorization_endpoint: mockConfig.authority.authorizeEndpoint, + token_endpoint: mockConfig.authority.tokenEndpoint, + jwks_uri: 'https://authority.local/.well-known/jwks.json', + response_types_supported: ['code'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], +}; + +const doctorSession = { + ...policyAuthorSession, + scopes: [ + ...new Set([ + ...policyAuthorSession.scopes, + 'ui.read', + 'admin', + 'ui.admin', + 'health:read', + 'doctor:read', + 'advisory-ai:view', + 'advisory-ai:operate', + 'advisory-ai:admin', + ]), + ], +}; + +const mockPlugins = { + plugins: [ + { + pluginId: 'integration.registry', + displayName: 'Registry Integration', + category: 'integration', + version: '1.0.0', + checkCount: 3, + }, + ], + total: 1, +}; + +const mockChecks = { + checks: [ + { + checkId: 'integration.registry.v2-endpoint', + name: 'V2 Endpoint Check', + description: 'Verify OCI registry V2 API endpoint accessibility', + pluginId: 'integration.registry', + category: 'integration', + defaultSeverity: 'fail', + tags: ['registry', 'oci', 'connectivity'], + estimatedDurationMs: 5000, + }, + { + checkId: 'integration.registry.auth-config', + name: 'Authentication Config', + description: 'Validate registry authentication configuration', + pluginId: 'integration.registry', + category: 'integration', + defaultSeverity: 'fail', + tags: ['registry', 'oci', 'auth'], + estimatedDurationMs: 3000, + }, + { + checkId: 'integration.registry.referrers-api', + name: 'Referrers API Support', + description: 'Detect OCI 1.1 Referrers API support', + pluginId: 'integration.registry', + category: 'integration', + defaultSeverity: 'warn', + tags: ['registry', 'oci', 'referrers'], + estimatedDurationMs: 4000, + }, + ], + total: 3, +}; + +test.describe('Unified Search - Live contextual suggestions', () => { + test.describe.configure({ mode: 'serial' }); + test.skip(!liveSearchBaseUrl, 'Set LIVE_ADVISORYAI_SEARCH_BASE_URL to a running local AdvisoryAI service.'); + + test.beforeAll(async () => { + await ensureLiveServiceHealthy(liveSearchBaseUrl); + await rebuildLiveIndexes(liveSearchBaseUrl); + }); + + test.beforeEach(async ({ page }) => { + await setupDoctorPage(page); + }); + + test('shows automatic suggestion chips when the doctor page opens', async ({ page }) => { + await routeLiveUnifiedSearch(page); + await openDoctor(page); + + const searchInput = page.locator('app-global-search input[type="text"]'); + await searchInput.focus(); + await waitForResults(page); + + await expect(page.locator('.search__context-title')).toContainText(/doctor diagnostics/i); + await expect(page.locator('.search__context-token', { + hasText: /scope:\s+knowledge/i, + }).first()).toBeVisible(); + await expect(page.locator('.search__suggestions .search__chip', { + hasText: /database connectivity/i, + }).first()).toBeVisible(); + await expect(page.locator('.search__suggestions .search__chip', { + hasText: /oidc readiness/i, + }).first()).toBeVisible(); + }); + + test('clicking a suggestion chip executes a live query and shows a grounded answer', async ({ page }) => { + const capturedRequests: Array> = []; + await routeLiveUnifiedSearch(page, capturedRequests); + await openDoctor(page); + + const searchInput = page.locator('app-global-search input[type="text"]'); + await searchInput.focus(); + await waitForResults(page); + await page.locator('.search__suggestions .search__chip', { + hasText: /database connectivity/i, + }).first().click(); + + await expect.poll(() => + capturedRequests.some((request) => String(request['q'] ?? '').toLowerCase() === 'database connectivity'), + ).toBe(true); + await expect(searchInput).toHaveValue('database connectivity'); + await waitForResults(page); + await waitForEntityCards(page, 1); + + await expect(page.locator('[data-answer-status="grounded"]')).toBeVisible(); + await expect(page.locator('app-entity-card').first()).toContainText(/postgresql connectivity/i); + + const matchingRequest = capturedRequests.find((request) => + String(request['q'] ?? '').toLowerCase() === 'database connectivity'); + const ambient = matchingRequest?.['ambient'] as Record | undefined; + + expect(matchingRequest?.['q']).toBe('database connectivity'); + expect(String(ambient?.['currentRoute'] ?? '')).toContain('/ops/operations/doctor'); + }); + + test('opening a live result promotes a follow-up chip from the last action', async ({ page }) => { + await routeLiveUnifiedSearch(page); + await openDoctor(page); + + const searchInput = page.locator('app-global-search input[type="text"]'); + await searchInput.focus(); + await waitForResults(page); + await page.locator('.search__suggestions .search__chip', { + hasText: /database connectivity/i, + }).first().click(); + + await expect(searchInput).toHaveValue('database connectivity'); + await waitForResults(page); + await waitForEntityCards(page, 1); + await page.locator('app-entity-card').first().click(); + + await expect(page).toHaveURL(/\/ops\/operations\/doctor\?check=check\.core\.db\.connectivity/i); + + await page.locator('app-global-search input[type="text"]').focus(); + await waitForResults(page); + + await expect(page.locator('.search__context-token', { + hasText: /last action:\s+opened result for database connectivity/i, + }).first()).toBeVisible(); + await expect(page.locator('.search__suggestions .search__chip', { + hasText: /follow up:\s*database connectivity/i, + }).first()).toBeVisible(); + }); +}); + +async function setupDoctorPage(page: Page): Promise { + await page.addInitScript((stubSession) => { + (window as unknown as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = stubSession; + }, doctorSession); + + await page.route('**/config.json', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mockConfig), + }), + ); + await page.route('**/platform/envsettings.json', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mockConfig), + }), + ); + await page.route('https://authority.local/**', (route) => { + const url = route.request().url(); + if (url.includes('/.well-known/openid-configuration')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(oidcConfig), + }); + } + if (url.includes('/.well-known/jwks.json')) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ keys: [] }), + }); + } + return route.abort(); + }); + + await page.route('**/doctor/api/v1/doctor/plugins**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mockPlugins), + }), + ); + await page.route('**/doctor/api/v1/doctor/checks**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mockChecks), + }), + ); + await page.route('**/doctor/api/v1/doctor/run', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ runId: 'dr-live-001' }), + }), + ); + await page.route('**/doctor/api/v1/doctor/run/**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + runId: 'dr-live-001', + status: 'completed', + startedAt: '2026-03-07T10:00:00Z', + completedAt: '2026-03-07T10:00:08Z', + durationMs: 8000, + summary: { passed: 2, info: 0, warnings: 1, failed: 0, skipped: 0, total: 3 }, + overallSeverity: 'warn', + results: [], + }), + }), + ); +} + +async function openDoctor(page: Page): Promise { + await page.goto('/ops/operations/doctor', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: /doctor diagnostics/i })).toBeVisible({ + timeout: 15_000, + }); +} + +async function routeLiveUnifiedSearch( + page: Page, + capturedRequests?: Array>, +): Promise { + await page.route('**/api/v1/search/query', async (route) => { + const rawBody = route.request().postData() ?? '{}'; + const parsedBody = safeParseRequest(rawBody); + if (capturedRequests) { + capturedRequests.push(parsedBody); + } + + const response = await fetch(`${liveSearchBaseUrl}/v1/search/query`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-stellaops-scopes': liveScopes, + 'x-stellaops-tenant': liveTenant, + 'x-stellaops-actor': 'playwright-live', + }, + body: rawBody, + }); + + const body = await response.text(); + await route.fulfill({ + status: response.status, + contentType: response.headers.get('content-type') ?? 'application/json', + body, + }); + }); +} + +async function ensureLiveServiceHealthy(baseUrl: string): Promise { + const response = await fetch(`${baseUrl}/health`); + if (!response.ok) { + throw new Error(`Live AdvisoryAI health check failed with status ${response.status}.`); + } +} + +async function rebuildLiveIndexes(baseUrl: string): Promise { + const headers = { + 'content-type': 'application/json', + 'x-stellaops-scopes': 'advisory-ai:admin', + 'x-stellaops-tenant': liveTenant, + 'x-stellaops-actor': 'playwright-live', + }; + + const knowledgeResponse = await fetch(`${baseUrl}/v1/advisory-ai/index/rebuild`, { + method: 'POST', + headers, + }); + if (!knowledgeResponse.ok) { + throw new Error(`Knowledge rebuild failed with status ${knowledgeResponse.status}.`); + } + + const unifiedResponse = await fetch(`${baseUrl}/v1/search/index/rebuild`, { + method: 'POST', + headers, + }); + if (!unifiedResponse.ok) { + throw new Error(`Unified rebuild failed with status ${unifiedResponse.status}.`); + } +} + +function safeParseRequest(rawBody: string): Record { + try { + const parsed = JSON.parse(rawBody) as Record; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} diff --git a/src/Web/StellaOps.Web/tests/e2e/unified-search-self-serve-answer-panel.e2e.spec.ts b/src/Web/StellaOps.Web/tests/e2e/unified-search-self-serve-answer-panel.e2e.spec.ts index 2678d51d7..27f597433 100644 --- a/src/Web/StellaOps.Web/tests/e2e/unified-search-self-serve-answer-panel.e2e.spec.ts +++ b/src/Web/StellaOps.Web/tests/e2e/unified-search-self-serve-answer-panel.e2e.spec.ts @@ -80,11 +80,14 @@ test.describe('Unified Search - Self-Serve Answer Panel', () => { await page.locator('app-global-search input[type="text"]').focus(); await waitForResults(page); - await expect(page.locator('[data-common-question]')).toContainText([ + const commonQuestions = page.locator('[data-common-question]'); + await expect(commonQuestions).toHaveCount(3); + const commonQuestionTexts = (await commonQuestions.allTextContents()).map((text) => text.trim()); + expect(commonQuestionTexts).toEqual(expect.arrayContaining([ 'Why is this exploitable in my environment?', 'What evidence blocks this release?', 'What is the safest remediation path?', - ]); + ])); await typeInSearch(page, 'critical findings'); await waitForResults(page);