Document local CLI setup and harden live search suggestions

This commit is contained in:
master
2026-03-07 03:12:40 +02:00
parent 5e15ab15b1
commit 28932d4a85
10 changed files with 551 additions and 24 deletions

View File

@@ -529,6 +529,29 @@ stella doctor suggest "gateway returns 404 on known route" --json
## stella advisoryai index rebuild ## 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. Rebuild the AdvisoryAI deterministic knowledge index from local markdown, OpenAPI specs, and Doctor metadata.
### Synopsis ### Synopsis

View File

@@ -312,15 +312,52 @@ docker compose -f devops/compose/docker-compose.advisoryai-knowledge-test.yml up
# Wait for health check # Wait for health check
docker compose -f devops/compose/docker-compose.advisoryai-knowledge-test.yml ps docker compose -f devops/compose/docker-compose.advisoryai-knowledge-test.yml ps
# Prepare sources and rebuild index # Start the local AdvisoryAI service against that database
stella advisoryai sources prepare --json export ADVISORYAI__AdvisoryAI__KnowledgeSearch__ConnectionString="Host=localhost;Port=55432;Database=advisoryai_knowledge_test;Username=stellaops_knowledge;Password=stellaops_knowledge"
stella advisoryai index rebuild --json 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) # Run tests with the Live category (requires database)
dotnet test "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" \ dotnet test "src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj" \
--filter "Category=Live" -v normal --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: Or use the full CI testing stack:
```bash ```bash
docker compose -f devops/compose/docker-compose.testing.yml --profile ci up -d docker compose -f devops/compose/docker-compose.testing.yml --profile ci up -d

View File

@@ -20,6 +20,29 @@
### Installation ### 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) #### Option 1: .NET Tool (Recommended)
```bash ```bash

View File

@@ -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: Migrations run automatically when the service starts (`EnsureSchemaAsync()`). Or run them manually via the service:
```bash ```bash
# Configure connection string and rebuild the index (runs migrations + full index rebuild) # Configure connection string for the local AdvisoryAI WebService
export AdvisoryAI__KnowledgeSearch__ConnectionString="Host=localhost;Port=55432;Database=advisoryai_knowledge_test;Username=stellaops_knowledge;Password=stellaops_knowledge" 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 #### CLI availability in a source checkout
stella advisoryai index rebuild --json
# Or via HTTP (service must be running) Do not assume `stella` already exists on `PATH` in a local repo checkout.
curl -X POST https://localhost:10450/v1/advisory-ai/index/rebuild \ 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-Scopes: advisory-ai:admin" \
-H "X-StellaOps-Tenant: test-tenant" -H "X-StellaOps-Tenant: test-tenant"
``` ```

View File

@@ -24023,7 +24023,7 @@ stella policy test {policyName}.stella
bool verbose, bool verbose,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var client = services.GetRequiredService<IOrchestratorClient>(); var client = services.GetRequiredService<IJobEngineClient>();
var request = new SourceListRequest var request = new SourceListRequest
{ {
@@ -24082,7 +24082,7 @@ stella policy test {policyName}.stella
bool verbose, bool verbose,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var client = services.GetRequiredService<IOrchestratorClient>(); var client = services.GetRequiredService<IJobEngineClient>();
try try
{ {
@@ -24380,7 +24380,7 @@ stella policy test {policyName}.stella
bool verbose, bool verbose,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var client = services.GetRequiredService<IOrchestratorClient>(); var client = services.GetRequiredService<IJobEngineClient>();
var request = new SourceTestRequest var request = new SourceTestRequest
{ {
@@ -24466,7 +24466,7 @@ stella policy test {policyName}.stella
bool verbose, bool verbose,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var client = services.GetRequiredService<IOrchestratorClient>(); var client = services.GetRequiredService<IJobEngineClient>();
var request = new SourcePauseRequest var request = new SourcePauseRequest
{ {
@@ -24546,7 +24546,7 @@ stella policy test {policyName}.stella
bool verbose, bool verbose,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var client = services.GetRequiredService<IOrchestratorClient>(); var client = services.GetRequiredService<IJobEngineClient>();
var request = new SourceResumeRequest var request = new SourceResumeRequest
{ {
@@ -24629,7 +24629,7 @@ stella policy test {policyName}.stella
bool verbose, bool verbose,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var client = services.GetRequiredService<IOrchestratorClient>(); var client = services.GetRequiredService<IJobEngineClient>();
var request = new BackfillRequest var request = new BackfillRequest
{ {
@@ -24741,7 +24741,7 @@ stella policy test {policyName}.stella
bool verbose, bool verbose,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var client = services.GetRequiredService<IOrchestratorClient>(); var client = services.GetRequiredService<IJobEngineClient>();
try try
{ {
@@ -24848,7 +24848,7 @@ stella policy test {policyName}.stella
bool verbose, bool verbose,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var client = services.GetRequiredService<IOrchestratorClient>(); var client = services.GetRequiredService<IJobEngineClient>();
var request = new BackfillListRequest var request = new BackfillListRequest
{ {
@@ -24934,7 +24934,7 @@ stella policy test {policyName}.stella
bool verbose, bool verbose,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var client = services.GetRequiredService<IOrchestratorClient>(); var client = services.GetRequiredService<IJobEngineClient>();
var request = new BackfillCancelRequest var request = new BackfillCancelRequest
{ {
@@ -25000,7 +25000,7 @@ stella policy test {policyName}.stella
bool verbose, bool verbose,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var client = services.GetRequiredService<IOrchestratorClient>(); var client = services.GetRequiredService<IJobEngineClient>();
var request = new QuotaGetRequest var request = new QuotaGetRequest
{ {
@@ -25100,7 +25100,7 @@ stella policy test {policyName}.stella
bool verbose, bool verbose,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var client = services.GetRequiredService<IOrchestratorClient>(); var client = services.GetRequiredService<IJobEngineClient>();
var request = new QuotaSetRequest var request = new QuotaSetRequest
{ {
@@ -25172,7 +25172,7 @@ stella policy test {policyName}.stella
bool verbose, bool verbose,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var client = services.GetRequiredService<IOrchestratorClient>(); var client = services.GetRequiredService<IJobEngineClient>();
var request = new QuotaResetRequest var request = new QuotaResetRequest
{ {

View File

@@ -281,7 +281,7 @@ internal static class Program
}).AddEgressPolicyGuard("stellaops-cli", "exceptions-api"); }).AddEgressPolicyGuard("stellaops-cli", "exceptions-api");
// CLI-ORCH-32-001: Orchestrator client for source/job management // CLI-ORCH-32-001: Orchestrator client for source/job management
services.AddHttpClient<IOrchestratorClient, OrchestratorClient>(client => services.AddHttpClient<IJobEngineClient, JobEngineClient>(client =>
{ {
client.Timeout = TimeSpan.FromSeconds(60); client.Timeout = TimeSpan.FromSeconds(60);
if (!string.IsNullOrWhiteSpace(options.BackendUrl) && if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&

View File

@@ -1962,6 +1962,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
}); });
this.query.set(query); this.query.set(query);
this.searchTerms$.next(query.trim()); this.searchTerms$.next(query.trim());
this.keepSearchSurfaceOpen();
} }
applyExampleQuery(example: string): void { applyExampleQuery(example: string): void {
@@ -1971,6 +1972,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
}); });
this.query.set(example); this.query.set(example);
this.searchTerms$.next(example.trim()); this.searchTerms$.next(example.trim());
this.keepSearchSurfaceOpen();
} }
applyQuestionQuery(query: string, source: 'common' | 'answer' | 'clarify'): void { applyQuestionQuery(query: string, source: 'common' | 'answer' | 'clarify'): void {
@@ -1985,6 +1987,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
}); });
this.query.set(query); this.query.set(query);
this.searchTerms$.next(query.trim()); this.searchTerms$.next(query.trim());
this.keepSearchSurfaceOpen();
} }
applyAnswerNextSearch(query: string): void { applyAnswerNextSearch(query: string): void {
@@ -1994,6 +1997,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
}); });
this.query.set(query); this.query.set(query);
this.searchTerms$.next(query.trim()); this.searchTerms$.next(query.trim());
this.keepSearchSurfaceOpen();
} }
applySuggestion(text: string): void { applySuggestion(text: string): void {
@@ -2004,6 +2008,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
this.query.set(text); this.query.set(text);
this.saveRecentSearch(text); this.saveRecentSearch(text);
this.searchTerms$.next(text.trim()); this.searchTerms$.next(text.trim());
this.keepSearchSurfaceOpen();
} }
applyRefinement(refinement: SearchRefinement): void { applyRefinement(refinement: SearchRefinement): void {
@@ -2014,6 +2019,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
this.query.set(refinement.text); this.query.set(refinement.text);
this.saveRecentSearch(refinement.text); this.saveRecentSearch(refinement.text);
this.searchTerms$.next(refinement.text.trim()); this.searchTerms$.next(refinement.text.trim());
this.keepSearchSurfaceOpen();
} }
navigateQuickAction(route: string): void { 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( private scoreSuggestionForMode(
suggestion: { suggestion: {
kind?: 'page' | 'recent' | 'strategy'; kind?: 'page' | 'recent' | 'strategy';

View File

@@ -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 ({ test('chat search-for-more emits ambient lastAction and route context in follow-up search requests', async ({
page, page,
}) => { }) => {

View File

@@ -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<Record<string, unknown>> = [];
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<string, unknown> | 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<void> {
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<void> {
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<Record<string, unknown>>,
): Promise<void> {
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<void> {
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<void> {
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<string, unknown> {
try {
const parsed = JSON.parse(rawBody) as Record<string, unknown>;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}

View File

@@ -80,11 +80,14 @@ test.describe('Unified Search - Self-Serve Answer Panel', () => {
await page.locator('app-global-search input[type="text"]').focus(); await page.locator('app-global-search input[type="text"]').focus();
await waitForResults(page); 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?', 'Why is this exploitable in my environment?',
'What evidence blocks this release?', 'What evidence blocks this release?',
'What is the safest remediation path?', 'What is the safest remediation path?',
]); ]));
await typeInSearch(page, 'critical findings'); await typeInSearch(page, 'critical findings');
await waitForResults(page); await waitForResults(page);