Document local CLI setup and harden live search suggestions
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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) &&
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -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 {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user