diff --git a/docs/implplan/SPRINT_20260312_001_Platform_search_result_action_and_canonical_route_repair.md b/docs/implplan/SPRINT_20260312_001_Platform_search_result_action_and_canonical_route_repair.md new file mode 100644 index 000000000..754643f94 --- /dev/null +++ b/docs/implplan/SPRINT_20260312_001_Platform_search_result_action_and_canonical_route_repair.md @@ -0,0 +1,84 @@ +# Sprint 20260312_001 - Platform Search Result Action And Canonical Route Repair + +## Topic & Scope +- Expand live search verification beyond starter-chip execution into direct typed-query result actions, because user-reported `cve` searches still exposed broken or misleading result behavior. +- Repair the search slice as one product surface: query-entry loading state, advisory-vs-API ranking, API-card action semantics, and canonical docs navigation. +- Rebuild the touched backend and web surfaces, rerun the live Playwright sweep on the real frontdoor, and only then close the iteration with a local commit. +- Working directory: `.`. +- Expected evidence: focused AdvisoryAI/Web tests, live Playwright result-action sweep output, rebuilt live stack proof, and updated search docs. + +## Dependencies & Concurrency +- Depends on the clean scratch iteration baseline in `docs/implplan/SPRINT_20260311_014_Platform_scratch_iteration_003_full_route_action_audit.md`. +- Safe parallelism: scoped to AdvisoryAI unified-search, web global-search, live Playwright scripts, and documentation updates for search behavior. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/qa/feature-checks/FLOW.md` +- `docs/modules/ui/search-zero-learning-primary-entry.md` +- `docs/modules/advisory-ai/knowledge-search.md` +- `src/AdvisoryAI/__Tests/INFRASTRUCTURE.md` + +## Delivery Tracker + +### PLATFORM-SEARCH-001 - Capture the real live failures on direct search result actions +Status: DONE +Dependency: none +Owners: QA +Task description: +- Run direct typed-query Playwright verification against the live authenticated frontdoor instead of relying only on suggestion-chip coverage. +- The sweep must exercise generic advisory intent and explicit API intent, record which cards appear first, and verify that result actions lead to the expected product surfaces. + +Completion criteria: +- [x] Live typed-query result evidence is captured. +- [x] The sweep distinguishes product defects from harness false positives. +- [x] The failing behaviors are enumerated before fixes begin. + +### PLATFORM-SEARCH-002 - Root-cause and implement the clean search-slice repair +Status: DONE +Dependency: PLATFORM-SEARCH-001 +Owners: 3rd line support, Product Manager, Architect, Developer +Task description: +- Repair the live search slice without quick fixes. The chosen solution must preserve search-first operator behavior and avoid dead-end or recovery-only routes. +- Expected repair areas include query-entry state, ranking semantics, card-action contracts, and canonical route normalization where the live shell still leaks encoded or placeholder routes. + +Completion criteria: +- [x] Generic advisory queries prefer findings/VEX over API-operation cards unless the query explicitly asks for API details. +- [x] API cards use truthful copy-first actions instead of dead-end navigation. +- [x] Global-search action routing canonicalizes docs targets instead of navigating through double-encoded recovery paths. +- [x] Focused frontend/backend tests cover the repaired behavior. + +### PLATFORM-SEARCH-003 - Rebuild, redeploy, and reverify the live search slice +Status: DOING +Dependency: PLATFORM-SEARCH-002 +Owners: QA, Developer +Task description: +- Rebuild the touched AdvisoryAI and web surfaces, redeploy them to the live compose stack, and rerun the live search result-action sweep plus the aggregate route/action audit. + +Completion criteria: +- [x] AdvisoryAI targeted verification passes with project-compliant targeting. +- [x] Web targeted verification passes. +- [x] Live Playwright search result-action evidence is clean after redeploy. +- [ ] The iteration is committed locally with docs updated. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-12 | Sprint created after user-reported direct search failures showed the existing starter-chip matrix was not enough. | QA | +| 2026-03-12 | Added a live direct search result-action sweep and captured the real before-state: generic `cve` searches on the live stack still needed grouped verification for ranking, action handoffs, and docs-route canonicalization. The first sweep also exposed harness false positives around route-local result expectations and docs-shell async hydration. | QA / 3rd line support | +| 2026-03-12 | Root-caused the user-visible search defects to a product split: query-entry loading was not set until after debounce, generic advisory keywords could still underweight findings/VEX when no full CVE token was detected, API operation cards still depended on misleading result semantics, and docs result actions were allowed to navigate to double-encoded recovery URLs. | 3rd line support / Architect | +| 2026-03-12 | Began the clean repair: global-search now enters loading immediately on non-empty queries, direct search QA now distinguishes route-local expectations from global expectations, and docs-action normalization plus search-result routing coverage were added so canonical routes can be verified before commit. | Developer | +| 2026-03-12 | Reverified the focused repair layers before deploy: `GlobalSearchComponent` + `search-route-matrix` specs passed `4/4`, and xUnit v3 class-targeted AdvisoryAI runs passed `28/28` (`QueryUnderstandingTests`), `10/10` (`WeightedRrfFusionTests`), and `45/45` (`UnifiedSearchServiceTests`). | QA / Developer | +| 2026-03-12 | Rebuilt `stellaops/advisory-ai-web:dev` and `stellaops/advisory-ai-worker:dev`, rebuilt the web bundle, redeployed both AdvisoryAI services, synced the new browser dist into `compose_console-dist`, and restarted `stellaops-router-gateway`. AdvisoryAI startup rebuild converged cleanly with `documents=470`, `chunks=9051`, `api_operations=2190`, `doctor_projections=8`. | Developer / 3rd line support | +| 2026-03-12 | Live direct search result-action verification is now clean on the rebuilt stack: `failedCheckCount=0`, `runtimeIssueCount=0` in `src/Web/StellaOps.Web/output/playwright/live-search-result-action-sweep.json`, with generic `cve` queries grounding into Findings/VEX plus canonical docs navigation and explicit API-intent queries surfacing copy-first API cards. | QA | +| 2026-03-12 | Started the aggregate live audit rerun with the new search-result suite included. The 111-route canonical sweep has already completed cleanly (`passedRoutes=111`, `failedRoutes=[]`) and downstream action-suite reruns are still in progress. | QA | + +## Decisions & Risks +- Decision: direct typed-query result actions are now part of the search release gate. Starter-chip execution alone is insufficient because it misses ranking/order and result-card action defects. +- Decision: generic advisory/security-id intent must favor operator evidence over raw API references. API cards remain discoverable for explicit API-intent queries instead of polluting default advisory searches. +- Decision: canonical route normalization is a product requirement. A docs page that recovers from `/docs/docs%2F...` is still considered broken behavior until search emits or normalizes the correct route. +- Risk: several search-related source changes were already present locally and partially live from prior rebuilds without a commit. This sprint audits and formalizes that work instead of treating the dirty tree as trustworthy by default. + +## Next Checkpoints +- Finish the route-normalization and search-sweep repair. +- Rebuild `advisory-ai-web` and the web bundle on the live stack. +- Rerun the direct search result-action sweep and fold it into the full live audit. diff --git a/docs/modules/advisory-ai/knowledge-search.md b/docs/modules/advisory-ai/knowledge-search.md index 443e24139..3bc4f15be 100644 --- a/docs/modules/advisory-ai/knowledge-search.md +++ b/docs/modules/advisory-ai/knowledge-search.md @@ -145,6 +145,7 @@ Implemented in `src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/KnowledgeSea - Ranking determinism: - Freshness boost is disabled by default and only applies when `UnifiedFreshnessBoostEnabled` is explicitly enabled. - Ranking no longer depends on ambient wall-clock time unless that option is enabled. + - Generic advisory/security-id intent (`cve`, `ghsa`, vulnerability/advisory wording) boosts findings and VEX evidence ahead of API-operation knowledge cards unless the query explicitly asks for API/endpoint material. - Query telemetry: - Unified search emits hashed query telemetry (`SHA-256` query hash, intent, domain weights, latency, top domains) via `IUnifiedSearchTelemetrySink`. - Search analytics persistence stores hashed query keys (`SHA-256`, normalized) and pseudonymous user keys (tenant+user hash) in analytics/feedback artifacts. @@ -180,7 +181,7 @@ Global search now consumes AKS and supports: - Type filter chips. - Result actions: - Docs: `Open`. - - API: `Curl` (copy command). + - API: `Copy Curl` plus `Copy Operation ID`; API cards must not navigate to dead-end placeholder routes. - Doctor: `Run` (navigate to doctor and copy run command). - `More` action for "show more like this" local query expansion. - A shared mode switch (`Find`, `Explain`, `Act`) across search and AdvisoryAI with mode-aware chip ranking and handoff prompts. diff --git a/docs/modules/ui/search-zero-learning-primary-entry.md b/docs/modules/ui/search-zero-learning-primary-entry.md index 23cc900d3..2dc7f740c 100644 --- a/docs/modules/ui/search-zero-learning-primary-entry.md +++ b/docs/modules/ui/search-zero-learning-primary-entry.md @@ -41,6 +41,8 @@ 10. Search should summarize close evidence automatically. AdvisoryAI expands detail; it should not be required to make the primary result understandable. 11. Search suggestions are only valid when end-to-end execution against the active ingested corpus returns a grounded or materially useful answer. 12. Search verification is incomplete until live ingestion-backed route coverage proves the surfaced suggestions on every supported page family. +13. Generic advisory queries such as `cve` or `ghsa` must rank operator-facing findings/VEX evidence before raw API operation cards unless the query explicitly asks for API/endpoint details. +14. Search actions must land on canonical Stella routes. Recovery-only URLs such as double-encoded docs paths are not release-ready behavior. ## Target interaction model ### Entry diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/QueryUnderstanding/DomainWeightCalculator.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/QueryUnderstanding/DomainWeightCalculator.cs index 87451625c..c1523cf3c 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/QueryUnderstanding/DomainWeightCalculator.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/QueryUnderstanding/DomainWeightCalculator.cs @@ -40,8 +40,10 @@ internal sealed class DomainWeightCalculator var hasCve = entities.Any(static e => e.EntityType.Equals("cve", StringComparison.OrdinalIgnoreCase) || e.EntityType.Equals("ghsa", StringComparison.OrdinalIgnoreCase)); + var mentionsSecurityIdFamily = query.Contains("cve", StringComparison.OrdinalIgnoreCase) || + query.Contains("ghsa", StringComparison.OrdinalIgnoreCase); - if (hasCve) + if (hasCve || mentionsSecurityIdFamily) { weights["findings"] += tuning.CveBoostFindings; weights["vex"] += tuning.CveBoostVex; diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/UnifiedSearchService.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/UnifiedSearchService.cs index b8670c8af..867204473 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/UnifiedSearchService.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/UnifiedSearchService.cs @@ -1718,19 +1718,18 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService { var method = GetMetadataString(metadata, "method") ?? "GET"; var path = GetMetadataString(metadata, "path") ?? "/"; - var service = GetMetadataString(metadata, "service") ?? "unknown"; var operationId = GetMetadataString(metadata, "operationId") ?? row.Title; actions.Add(new EntityCardAction( - "Open", - "navigate", - $"/ops/integrations?q={Uri.EscapeDataString(operationId)}", - null, - true)); - actions.Add(new EntityCardAction( - "Curl", + "Copy Curl", "copy", null, $"curl -X {method.ToUpperInvariant()} \"$STELLAOPS_API_BASE{path}\"", + true)); + actions.Add(new EntityCardAction( + "Copy Operation ID", + "copy", + null, + operationId, false)); break; } diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/WeightedRrfFusion.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/WeightedRrfFusion.cs index 698b16b5e..9bafac02a 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/WeightedRrfFusion.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/WeightedRrfFusion.cs @@ -7,6 +7,7 @@ internal static class WeightedRrfFusion private const int ReciprocalRankConstant = 60; private const double EntityProximityBoost = 0.8; private const double MaxFreshnessBoost = 0.05; + private const double ApiOperationAdvisoryPenalty = -0.08; private const int FreshnessDaysCap = 365; public static IReadOnlyList<(KnowledgeChunkRow Row, double Score, IReadOnlyDictionary Debug)> Fuse( @@ -67,12 +68,14 @@ internal static class WeightedRrfFusion ? ComputeFreshnessBoost(item.Row, referenceTime ?? DateTimeOffset.UnixEpoch) : 0d; var popBoost = ComputePopularityBoost(item.Row, popularityMap, popularityBoostWeight); - item.Score += entityBoost + contextBoost + gravityBoost + freshnessBoost + popBoost; + var resultTypeAdjustment = ComputeResultTypeAdjustment(item.Row, query, detectedEntities); + item.Score += entityBoost + contextBoost + gravityBoost + freshnessBoost + popBoost + resultTypeAdjustment; item.Debug["entityBoost"] = entityBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture); item.Debug["contextBoost"] = contextBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture); item.Debug["gravityBoost"] = gravityBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture); item.Debug["freshnessBoost"] = freshnessBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture); item.Debug["popularityBoost"] = popBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture); + item.Debug["resultTypeAdjustment"] = resultTypeAdjustment.ToString("F6", System.Globalization.CultureInfo.InvariantCulture); item.Debug["chunkId"] = item.Row.ChunkId; return item; }) @@ -298,4 +301,66 @@ internal static class WeightedRrfFusion var entityKey = entityKeyProp.GetString(); return string.IsNullOrWhiteSpace(entityKey) ? null : entityKey.Trim(); } + + private static double ComputeResultTypeAdjustment( + KnowledgeChunkRow row, + string query, + IReadOnlyList? detectedEntities) + { + if (!string.Equals(row.Kind, "api_operation", StringComparison.OrdinalIgnoreCase)) + { + return 0d; + } + + if (!IsAdvisoryIntentQuery(query, detectedEntities) || IsExplicitApiIntentQuery(query)) + { + return 0d; + } + + return ApiOperationAdvisoryPenalty; + } + + private static bool IsAdvisoryIntentQuery( + string query, + IReadOnlyList? detectedEntities) + { + if (detectedEntities?.Any(static entity => + string.Equals(entity.EntityType, "cve", StringComparison.OrdinalIgnoreCase) || + string.Equals(entity.EntityType, "ghsa", StringComparison.OrdinalIgnoreCase)) == true) + { + return true; + } + + if (string.IsNullOrWhiteSpace(query)) + { + return false; + } + + return query.Contains("cve", StringComparison.OrdinalIgnoreCase) + || query.Contains("ghsa", StringComparison.OrdinalIgnoreCase) + || query.Contains("vulnerability", StringComparison.OrdinalIgnoreCase) + || query.Contains("advisory", StringComparison.OrdinalIgnoreCase) + || query.Contains("cvss", StringComparison.OrdinalIgnoreCase) + || query.Contains("epss", StringComparison.OrdinalIgnoreCase) + || query.Contains("kev", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsExplicitApiIntentQuery(string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + return false; + } + + return query.Contains("/api/", StringComparison.OrdinalIgnoreCase) + || query.Contains("curl", StringComparison.OrdinalIgnoreCase) + || query.Contains("endpoint", StringComparison.OrdinalIgnoreCase) + || query.Contains("operation", StringComparison.OrdinalIgnoreCase) + || query.Contains("route", StringComparison.OrdinalIgnoreCase) + || query.Contains("get ", StringComparison.OrdinalIgnoreCase) + || query.Contains("post ", StringComparison.OrdinalIgnoreCase) + || query.Contains("put ", StringComparison.OrdinalIgnoreCase) + || query.Contains("patch ", StringComparison.OrdinalIgnoreCase) + || query.Contains("delete ", StringComparison.OrdinalIgnoreCase); + } } diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/UnifiedSearch/QueryUnderstandingTests.cs b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/UnifiedSearch/QueryUnderstandingTests.cs index 592ed814f..3cbb1f51d 100644 --- a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/UnifiedSearch/QueryUnderstandingTests.cs +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/UnifiedSearch/QueryUnderstandingTests.cs @@ -133,6 +133,21 @@ public sealed class QueryUnderstandingTests weights["vex"].Should().BeGreaterThan(weights["knowledge"]); } + [Fact] + public void DomainWeightCalculator_boosts_generic_cve_keyword_queries() + { + var extractor = new EntityExtractor(); + var classifier = new IntentClassifier(); + var calculator = new DomainWeightCalculator(extractor, classifier, Options.Create(new KnowledgeSearchOptions())); + + var entities = extractor.Extract("cve"); + var weights = calculator.ComputeWeights("cve", entities, null); + + weights["findings"].Should().BeGreaterThan(weights["knowledge"]); + weights["vex"].Should().BeGreaterThan(weights["knowledge"]); + weights["graph"].Should().BeGreaterThan(weights["knowledge"]); + } + [Fact] public void DomainWeightCalculator_boosts_policy_for_policy_query() { diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/UnifiedSearch/UnifiedSearchServiceTests.cs b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/UnifiedSearch/UnifiedSearchServiceTests.cs index 4b9cb81cc..33281d003 100644 --- a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/UnifiedSearch/UnifiedSearchServiceTests.cs +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/UnifiedSearch/UnifiedSearchServiceTests.cs @@ -1383,6 +1383,13 @@ public sealed class UnifiedSearchServiceTests card.Preview.StructuredFields.Should().Contain(f => f.Label == "Summary" && f.Value == "Start a new scan"); card.Preview.Content.Should().Contain("curl"); card.Preview.Content.Should().Contain("POST"); + card.Actions.Should().HaveCountGreaterThanOrEqualTo(2); + card.Actions[0].Label.Should().Be("Copy Curl"); + card.Actions[0].ActionType.Should().Be("copy"); + card.Actions[0].Route.Should().BeNull(); + card.Actions[0].Command.Should().Contain("/api/v1/scanner/scans"); + card.Actions[1].Label.Should().Be("Copy Operation ID"); + card.Actions[1].Command.Should().Be("createScan"); } [Fact] diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/UnifiedSearch/WeightedRrfFusionTests.cs b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/UnifiedSearch/WeightedRrfFusionTests.cs index 1c19c5cd8..a93ee4909 100644 --- a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/UnifiedSearch/WeightedRrfFusionTests.cs +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/UnifiedSearch/WeightedRrfFusionTests.cs @@ -227,6 +227,72 @@ public sealed class WeightedRrfFusionTests "when popularity boost is disabled, ranking should match the baseline order"); } + [Fact] + public void Fuse_demotes_api_operations_for_advisory_intent_queries() + { + var weights = new Dictionary + { + ["knowledge"] = 1.0, + ["findings"] = 1.0 + }; + + using var apiMetadata = JsonDocument.Parse("""{"domain":"knowledge","entity_key":"api:search"}"""); + using var findingMetadata = JsonDocument.Parse("""{"domain":"findings","cveId":"CVE-2026-1234"}"""); + + var apiRow = MakeRow("chunk-api", "api_operation", "GET /api/v1/search/query", apiMetadata); + var findingRow = MakeRow("chunk-finding", "finding", "CVE-2026-1234 finding", findingMetadata); + + var lexical = new Dictionary(StringComparer.Ordinal) + { + ["chunk-api"] = ("chunk-api", 1, apiRow), + ["chunk-finding"] = ("chunk-finding", 2, findingRow) + }; + + var result = WeightedRrfFusion.Fuse( + weights, + lexical, + [], + "cve", + null); + + result.Should().HaveCount(2); + result[0].Row.ChunkId.Should().Be("chunk-finding"); + result.Single(item => item.Row.ChunkId == "chunk-api").Debug["resultTypeAdjustment"].Should().NotBe("0.000000"); + } + + [Fact] + public void Fuse_keeps_api_operations_ranked_for_explicit_api_queries() + { + var weights = new Dictionary + { + ["knowledge"] = 1.0, + ["findings"] = 1.0 + }; + + using var apiMetadata = JsonDocument.Parse("""{"domain":"knowledge","entity_key":"api:search"}"""); + using var findingMetadata = JsonDocument.Parse("""{"domain":"findings","cveId":"CVE-2026-1234"}"""); + + var apiRow = MakeRow("chunk-api", "api_operation", "GET /api/v1/search/query", apiMetadata); + var findingRow = MakeRow("chunk-finding", "finding", "CVE-2026-1234 finding", findingMetadata); + + var lexical = new Dictionary(StringComparer.Ordinal) + { + ["chunk-api"] = ("chunk-api", 1, apiRow), + ["chunk-finding"] = ("chunk-finding", 2, findingRow) + }; + + var result = WeightedRrfFusion.Fuse( + weights, + lexical, + [], + "cve api endpoint", + null); + + result.Should().HaveCount(2); + result[0].Row.ChunkId.Should().Be("chunk-api"); + result.Single(item => item.Row.ChunkId == "chunk-api").Debug["resultTypeAdjustment"].Should().Be("0.000000"); + } + private static KnowledgeChunkRow MakeRow( string chunkId, string kind, diff --git a/src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs b/src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs index c728975d4..3d4614588 100644 --- a/src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs +++ b/src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs @@ -107,6 +107,11 @@ const suites = [ script: 'live-frontdoor-unified-search-route-matrix.mjs', reportPath: path.join(outputDir, 'live-frontdoor-unified-search-route-matrix.json'), }, + { + name: 'search-result-action-sweep', + script: 'live-search-result-action-sweep.mjs', + reportPath: path.join(outputDir, 'live-search-result-action-sweep.json'), + }, ]; const failureCountKeys = new Set([ diff --git a/src/Web/StellaOps.Web/scripts/live-search-result-action-sweep.mjs b/src/Web/StellaOps.Web/scripts/live-search-result-action-sweep.mjs new file mode 100644 index 000000000..189d617af --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-search-result-action-sweep.mjs @@ -0,0 +1,493 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from 'playwright'; + +import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const webRoot = path.resolve(__dirname, '..'); +const outputDir = path.join(webRoot, 'output', 'playwright'); +const outputPath = path.join(outputDir, 'live-search-result-action-sweep.json'); +const authStatePath = path.join(outputDir, 'live-search-result-action-sweep.state.json'); +const authReportPath = path.join(outputDir, 'live-search-result-action-sweep.auth.json'); +const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local'; +const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d'; +const searchContexts = [ + { + label: 'mission-board-cve', + route: '/mission-control/board', + query: 'cve', + requireFindingAction: true, + requireVexAction: true, + requireKnowledgeAction: true, + requireKnowledgeCards: true, + requireVexCards: true, + requireCanonicalDocsRoute: true, + }, + { + label: 'triage-cve', + route: '/security/triage?pivot=cve', + query: 'cve', + requireFindingAction: true, + requireVexAction: false, + requireKnowledgeAction: false, + requireKnowledgeCards: false, + requireVexCards: false, + requireCanonicalDocsRoute: false, + }, + { + label: 'mission-board-api-operation', + route: '/mission-control/board', + query: 'scanner scans api', + requireFindingAction: false, + requireVexAction: false, + requireKnowledgeAction: false, + requireKnowledgeCards: false, + requireVexCards: false, + requireCanonicalDocsRoute: false, + requireApiCopyCard: true, + }, +]; + +function buildUrl(route) { + const separator = route.includes('?') ? '&' : '?'; + return `${baseUrl}${route}${separator}${scopeQuery}`; +} + +async function settle(page, ms = 1500) { + await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {}); + await page.waitForTimeout(ms); +} + +async function snapshot(page, label) { + const domSnapshot = await page.evaluate(() => { + const heading = + document.querySelector('h1, h2, [data-testid="page-title"], .page-title')?.textContent ?? ''; + const alerts = Array.from( + document.querySelectorAll('[role="alert"], .alert, .error-banner, .success-banner, .loading-text, .search__loading, .search__empty'), + ) + .map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()) + .filter(Boolean) + .slice(0, 10); + + return { + title: document.title ?? '', + heading: heading.replace(/\s+/g, ' ').trim(), + alerts, + }; + }).catch(() => ({ + title: '', + heading: '', + alerts: [], + })); + + return { + label, + url: page.url(), + title: domSnapshot.title, + heading: domSnapshot.heading, + alerts: domSnapshot.alerts, + }; +} + +async function waitForDestinationContent(page) { + await settle(page, 1500); + + if (!page.url().includes('/docs/')) { + return; + } + + await page.waitForFunction( + () => { + const main = document.querySelector('main'); + return typeof main?.textContent === 'string' && main.textContent.replace(/\s+/g, ' ').trim().length > 64; + }, + undefined, + { timeout: 10_000 }, + ).catch(() => {}); +} + +async function waitForSearchResolution(page, timeoutMs = 15_000) { + const startedAt = Date.now(); + let sawLoading = false; + + while (Date.now() - startedAt < timeoutMs) { + const state = await page.evaluate(() => ({ + cardCount: document.querySelectorAll('.entity-card').length, + loadingVisible: Array.from(document.querySelectorAll('.search__loading')) + .some((node) => (node.textContent || '').trim().length > 0), + emptyTexts: Array.from(document.querySelectorAll('.search__empty, .search__empty-state-copy')) + .map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()) + .filter(Boolean), + })).catch(() => ({ + cardCount: 0, + loadingVisible: false, + emptyTexts: [], + })); + + sawLoading ||= state.loadingVisible; + + if (state.cardCount > 0) { + return { + resolved: 'cards', + sawLoading, + cardCount: state.cardCount, + emptyTexts: state.emptyTexts, + waitedMs: Date.now() - startedAt, + }; + } + + if (!state.loadingVisible && state.emptyTexts.length > 0) { + return { + resolved: 'empty', + sawLoading, + cardCount: 0, + emptyTexts: state.emptyTexts, + waitedMs: Date.now() - startedAt, + }; + } + + await page.waitForTimeout(250); + } + + return { + resolved: 'timeout', + sawLoading, + cardCount: await page.locator('.entity-card').count().catch(() => 0), + emptyTexts: await page.locator('.search__empty, .search__empty-state-copy').evaluateAll( + (nodes) => nodes.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean), + ).catch(() => []), + waitedMs: Date.now() - startedAt, + }; +} + +async function collectCards(page) { + return page.locator('.entity-card').evaluateAll((nodes) => + nodes.slice(0, 8).map((node) => ({ + title: node.querySelector('.entity-card__title')?.textContent?.trim() || '', + domain: node.querySelector('.entity-card__badge')?.textContent?.trim() || '', + snippet: node.querySelector('.entity-card__snippet')?.textContent?.replace(/\s+/g, ' ').trim() || '', + actions: Array.from(node.querySelectorAll('.entity-card__action')).map((button) => ({ + label: button.textContent?.replace(/\s+/g, ' ').trim() || '', + isPrimary: button.classList.contains('entity-card__action--primary'), + })).filter((action) => action.label.length > 0), + })), + ).catch(() => []); +} + +async function executePrimaryAction(page, predicateLabel) { + const cards = page.locator('.entity-card'); + const count = await cards.count().catch(() => 0); + for (let index = 0; index < count; index += 1) { + const card = cards.nth(index); + const domain = await card.locator('.entity-card__badge').textContent().then((text) => text?.trim() || '').catch(() => ''); + if (!predicateLabel.test(domain)) { + continue; + } + + const actionButton = card.locator('.entity-card__action--primary').first(); + const actionLabel = await actionButton.textContent().then((text) => text?.replace(/\s+/g, ' ').trim() || '').catch(() => ''); + process.stdout.write(`[live-search-result-action-sweep] click domain=${domain} label="${actionLabel}" index=${index}\n`); + await actionButton.click({ timeout: 10_000 }).catch(() => {}); + process.stdout.write(`[live-search-result-action-sweep] clicked domain=${domain} url=${page.url()}\n`); + await waitForDestinationContent(page); + process.stdout.write(`[live-search-result-action-sweep] settled domain=${domain} url=${page.url()}\n`); + return { + matchedDomain: domain, + actionLabel, + url: page.url(), + }; + } + + return null; +} + +async function runSearchContext(page, context) { + const responses = []; + const responseListener = async (response) => { + if (!response.url().includes('/api/v1/search/query')) { + return; + } + + try { + const body = await response.json(); + responses.push({ + status: response.status(), + url: response.url(), + cards: (body.cards ?? []).slice(0, 8).map((card) => ({ + title: card.title ?? '', + domain: card.domain ?? '', + actions: (card.actions ?? []).map((action) => ({ + label: action.label ?? '', + actionType: action.actionType ?? '', + route: action.route ?? '', + })), + })), + diagnostics: body.diagnostics ?? null, + }); + } catch { + responses.push({ + status: response.status(), + url: response.url(), + cards: [], + diagnostics: null, + }); + } + }; + + page.on('response', responseListener); + try { + process.stdout.write(`[live-search-result-action-sweep] ${context.label} goto ${context.route}\n`); + await page.goto(buildUrl(context.route), { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await settle(page); + + const input = page.locator('input[aria-label="Global search"]').first(); + await input.click({ timeout: 10_000 }); + await input.fill(context.query); + + const resolution = await waitForSearchResolution(page); + process.stdout.write(`[live-search-result-action-sweep] ${context.label} resolved=${resolution.resolved} cards=${resolution.cardCount} waitedMs=${resolution.waitedMs}\n`); + const cards = await collectCards(page); + const latestResponse = responses.at(-1) ?? null; + + const result = { + label: context.label, + route: context.route, + query: context.query, + expectations: { + requireFindingAction: context.requireFindingAction === true, + requireVexAction: context.requireVexAction === true, + requireKnowledgeAction: context.requireKnowledgeAction === true, + requireKnowledgeCards: context.requireKnowledgeCards === true, + requireVexCards: context.requireVexCards === true, + requireCanonicalDocsRoute: context.requireCanonicalDocsRoute === true, + requireApiCopyCard: context.requireApiCopyCard === true, + }, + resolution, + cards, + latestResponse, + topCard: cards[0] ?? null, + baseSnapshot: await snapshot(page, `${context.label}:results`), + findingAction: null, + vexAction: null, + knowledgeAction: null, + }; + + if (cards.length > 0 && context.requireFindingAction) { + process.stdout.write(`[live-search-result-action-sweep] ${context.label} finding-action\n`); + await page.goto(buildUrl(context.route), { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await settle(page); + await page.locator('input[aria-label="Global search"]').first().click({ timeout: 10_000 }); + await page.locator('input[aria-label="Global search"]').first().fill(context.query); + await waitForSearchResolution(page); + result.findingAction = await executePrimaryAction(page, /^Findings$/i); + } + + if (cards.length > 0 && context.requireVexAction) { + process.stdout.write(`[live-search-result-action-sweep] ${context.label} vex-action\n`); + await page.goto(buildUrl(context.route), { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await settle(page); + await page.locator('input[aria-label="Global search"]').first().click({ timeout: 10_000 }); + await page.locator('input[aria-label="Global search"]').first().fill(context.query); + await waitForSearchResolution(page); + result.vexAction = await executePrimaryAction(page, /^VEX/i); + } + + if (cards.length > 0 && context.requireKnowledgeAction) { + process.stdout.write(`[live-search-result-action-sweep] ${context.label} knowledge-action\n`); + await page.goto(buildUrl(context.route), { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await settle(page); + await page.locator('input[aria-label="Global search"]').first().click({ timeout: 10_000 }); + await page.locator('input[aria-label="Global search"]').first().fill(context.query); + await waitForSearchResolution(page); + result.knowledgeAction = await executePrimaryAction(page, /^Knowledge$/i); + } + + return result; + } finally { + page.off('response', responseListener); + } +} + +function collectFailures(results) { + const failures = []; + + for (const result of results) { + const expectations = result.expectations ?? {}; + + if (result.resolution.resolved !== 'cards') { + failures.push(`${result.label}: query "${result.query}" did not resolve to visible result cards.`); + continue; + } + + if (!result.resolution.sawLoading) { + failures.push(`${result.label}: query "${result.query}" never showed a loading state before results.`); + } + + const topDomain = (result.topCard?.domain ?? '').toLowerCase(); + const topPrimary = result.topCard?.actions?.find((action) => action.isPrimary)?.label ?? ''; + if (result.query === 'cve' && (topDomain.includes('knowledge') || topDomain.includes('api') || /copy curl/i.test(topPrimary))) { + failures.push(`${result.label}: generic "${result.query}" search still ranks a knowledge/API card first (${result.topCard?.title ?? 'unknown'}).`); + } + + const findingRoute = result.latestResponse?.cards?.find((card) => card.domain === 'findings')?.actions?.[0]?.route ?? ''; + if (expectations.requireFindingAction && !findingRoute.startsWith('/security/triage')) { + failures.push(`${result.label}: findings result is missing the canonical triage route.`); + } + + const knowledgeRoute = result.latestResponse?.cards?.find((card) => card.domain === 'knowledge')?.actions?.[0]?.route ?? ''; + if (expectations.requireKnowledgeCards && !knowledgeRoute) { + failures.push(`${result.label}: query "${result.query}" did not surface a knowledge result.`); + } + + if (expectations.requireVexCards && !result.latestResponse?.cards?.some((card) => card.domain === 'vex')) { + failures.push(`${result.label}: query "${result.query}" did not surface a VEX result.`); + } + + if (knowledgeRoute && !knowledgeRoute.startsWith('/docs/')) { + failures.push(`${result.label}: knowledge result route is not a docs route (${knowledgeRoute}).`); + } + + if (expectations.requireFindingAction && !result.findingAction?.url?.includes('/security/triage')) { + failures.push(`${result.label}: primary finding action did not land on Security Triage.`); + } + + if (expectations.requireVexAction && !result.vexAction?.url?.includes('/security/advisories-vex')) { + failures.push(`${result.label}: primary VEX action did not land on Advisories & VEX.`); + } + + if (expectations.requireKnowledgeAction && !result.knowledgeAction?.url?.includes('/docs/')) { + failures.push(`${result.label}: primary knowledge action did not land on Documentation.`); + } + + if (expectations.requireCanonicalDocsRoute && result.knowledgeAction?.url?.includes('/docs/docs%2F')) { + failures.push(`${result.label}: primary knowledge action stayed on a non-canonical docs route (${result.knowledgeAction.url}).`); + } + + if (expectations.requireApiCopyCard) { + const apiCard = result.latestResponse?.cards?.find((card) => + card.actions?.[0]?.label === 'Copy Curl'); + if (!apiCard) { + failures.push(`${result.label}: explicit API query "${result.query}" did not surface a copy-first API card.`); + } else { + const primaryAction = apiCard.actions[0]; + const secondaryAction = apiCard.actions[1]; + if (primaryAction.actionType !== 'copy' || primaryAction.route) { + failures.push(`${result.label}: API card primary action is not a pure copy action.`); + } + if (secondaryAction?.label !== 'Copy Operation ID' || secondaryAction.actionType !== 'copy') { + failures.push(`${result.label}: API card secondary action is not "Copy Operation ID".`); + } + } + } + } + + return failures; +} + +function attachRuntimeListeners(page, runtime) { + page.on('console', (message) => { + if (message.type() === 'error') { + runtime.consoleErrors.push(message.text()); + } + }); + + page.on('pageerror', (error) => { + runtime.pageErrors.push(error.message); + }); + + page.on('requestfailed', (request) => { + const url = request.url(); + if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) { + return; + } + + const errorText = request.failure()?.errorText ?? 'unknown'; + if (errorText === 'net::ERR_ABORTED') { + return; + } + + runtime.requestFailures.push(`${request.method()} ${url} ${errorText}`); + }); + + page.on('response', (response) => { + const url = response.url(); + if (!url.includes('/api/v1/search/query')) { + return; + } + + if (response.status() >= 400) { + runtime.responseErrors.push(`${response.status()} ${response.request().method()} ${url}`); + } + }); +} + +async function main() { + await mkdir(outputDir, { recursive: true }); + + const browser = await chromium.launch({ headless: process.env.PLAYWRIGHT_HEADLESS !== 'false' }); + const auth = await authenticateFrontdoor({ statePath: authStatePath, reportPath: authReportPath }); + const context = await createAuthenticatedContext(browser, auth, { statePath: auth.statePath }); + + const runtime = { + consoleErrors: [], + pageErrors: [], + requestFailures: [], + responseErrors: [], + }; + + try { + const results = []; + for (const contextConfig of searchContexts) { + process.stdout.write(`[live-search-result-action-sweep] START ${contextConfig.label} query="${contextConfig.query}"\n`); + const page = await context.newPage(); + attachRuntimeListeners(page, runtime); + try { + // eslint-disable-next-line no-await-in-loop + results.push(await runSearchContext(page, contextConfig)); + } finally { + // eslint-disable-next-line no-await-in-loop + await page.close().catch(() => {}); + } + process.stdout.write(`[live-search-result-action-sweep] DONE ${contextConfig.label}\n`); + } + + const failures = collectFailures(results); + const report = { + generatedAtUtc: new Date().toISOString(), + baseUrl, + scopeQuery, + results, + runtime, + failedCheckCount: failures.length, + runtimeIssueCount: + runtime.consoleErrors.length + + runtime.pageErrors.length + + runtime.requestFailures.length + + runtime.responseErrors.length, + failures, + ok: + failures.length === 0 && + runtime.consoleErrors.length === 0 && + runtime.pageErrors.length === 0 && + runtime.requestFailures.length === 0 && + runtime.responseErrors.length === 0, + }; + + await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + + if (!report.ok) { + process.exitCode = 1; + } + } finally { + await context.close().catch(() => {}); + await browser.close().catch(() => {}); + } +} + +await main(); diff --git a/src/Web/StellaOps.Web/src/app/core/api/search.client.ts b/src/Web/StellaOps.Web/src/app/core/api/search.client.ts index a075b5499..8e9f603e3 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/search.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/search.client.ts @@ -13,6 +13,7 @@ import { SearchResultSeverity, ENTITY_TYPE_LABELS, } from './search.models'; +import { buildDocsRoute } from '../navigation/docs-route'; interface AdvisoryKnowledgeSearchRequestDto { q: string; @@ -295,7 +296,7 @@ export class SearchClient { } if (type === 'docs' && open.docs) { - return `/docs/${encodeURIComponent(open.docs.path)}#${encodeURIComponent(open.docs.anchor)}`; + return buildDocsRoute(open.docs.path, open.docs.anchor); } return undefined; diff --git a/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat.models.ts b/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat.models.ts index fc1cce3cc..0b56b0673 100644 --- a/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat.models.ts +++ b/src/Web/StellaOps.Web/src/app/features/advisory-ai/chat/chat.models.ts @@ -1,3 +1,5 @@ +import { buildDocsRoute } from '../../../core/navigation/docs-route'; + // ----------------------------------------------------------------------------- // chat.models.ts // Sprint: SPRINT_20260107_006_003_BE_advisoryai_chat @@ -280,7 +282,7 @@ export function getObjectLinkUrl(link: ParsedObjectLink): string { case 'auth': return `/settings/identity-providers?q=${encodeURIComponent(link.path)}`; case 'docs': - return `/docs/${encodeURIComponent(link.path)}`; + return buildDocsRoute(link.path); case 'finding': return `/security/findings/${encodeURIComponent(link.path)}`; case 'scan': diff --git a/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.spec.ts b/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.spec.ts new file mode 100644 index 000000000..e408cd651 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/layout/global-search/global-search.component.spec.ts @@ -0,0 +1,148 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { of, Subject } from 'rxjs'; + +import { UnifiedSearchClient } from '../../core/api/unified-search.client'; +import type { UnifiedSearchResponse } from '../../core/api/unified-search.models'; +import { I18nService } from '../../core/i18n'; +import { AmbientContextService } from '../../core/services/ambient-context.service'; +import { SearchAssistantDrawerService } from '../../core/services/search-assistant-drawer.service'; +import { SearchChatContextService } from '../../core/services/search-chat-context.service'; +import { TelemetryClient } from '../../core/telemetry/telemetry.client'; +import { GlobalSearchComponent } from './global-search.component'; + +@Component({ + standalone: true, + template: '', +}) +class DummyRouteComponent {} + +describe('GlobalSearchComponent', () => { + let fixture: ComponentFixture; + let component: GlobalSearchComponent; + let searchClient: jasmine.SpyObj; + let pendingResponse$: Subject; + let chatToSearchRequested$: Subject; + + beforeEach(async () => { + pendingResponse$ = new Subject(); + chatToSearchRequested$ = new Subject(); + searchClient = jasmine.createSpyObj( + 'UnifiedSearchClient', + ['search', 'submitFeedback', 'getHistory', 'recordAnalytics', 'evaluateSuggestions', 'clearHistory'], + ) as jasmine.SpyObj; + searchClient.search.and.returnValue(pendingResponse$.asObservable()); + searchClient.getHistory.and.returnValue(of([])); + searchClient.evaluateSuggestions.and.returnValue(of({ suggestions: [], coverage: null })); + + const ambientContext = jasmine.createSpyObj( + 'AmbientContextService', + [ + 'getSearchContextPanel', + 'getSearchSuggestions', + 'getCommonQuestions', + 'getClarifyingQuestions', + 'buildAmbientContext', + 'recordAction', + ], + ) as jasmine.SpyObj; + ambientContext.getSearchContextPanel.and.returnValue(null); + ambientContext.getSearchSuggestions.and.returnValue([]); + ambientContext.getCommonQuestions.and.returnValue([]); + ambientContext.getClarifyingQuestions.and.returnValue([]); + ambientContext.buildAmbientContext.and.returnValue({}); + + const searchChatContext = { + chatToSearchRequested$, + consumeChatToSearch: jasmine.createSpy('consumeChatToSearch').and.returnValue(null), + setSearchToChat: jasmine.createSpy('setSearchToChat'), + } as unknown as SearchChatContextService; + + const assistantDrawer = jasmine.createSpyObj('SearchAssistantDrawerService', ['open']) as unknown as SearchAssistantDrawerService; + const i18n = jasmine.createSpyObj('I18nService', ['tryT']) as unknown as jasmine.SpyObj; + i18n.tryT.and.returnValue(null); + const telemetry = jasmine.createSpyObj('TelemetryClient', ['emit']) as unknown as TelemetryClient; + + await TestBed.configureTestingModule({ + imports: [GlobalSearchComponent], + providers: [ + provideRouter([{ path: '**', component: DummyRouteComponent }]), + { provide: UnifiedSearchClient, useValue: searchClient }, + { provide: AmbientContextService, useValue: ambientContext }, + { provide: SearchChatContextService, useValue: searchChatContext }, + { provide: SearchAssistantDrawerService, useValue: assistantDrawer }, + { provide: I18nService, useValue: i18n }, + { provide: TelemetryClient, useValue: telemetry }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(GlobalSearchComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + pendingResponse$.complete(); + chatToSearchRequested$.complete(); + }); + + it('shows loading immediately for a typed query instead of a premature empty state', () => { + component.onFocus(); + component.onQueryChange('cve'); + fixture.detectChanges(); + + expect(component.isLoading()).toBeTrue(); + expect(fixture.nativeElement.querySelector('.search__loading')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('.search__empty')).toBeNull(); + }); + + it('replaces the loading state with results once the debounced search resolves', async () => { + const response: UnifiedSearchResponse = { + query: 'cve', + topK: 10, + cards: [ + { + entityKey: 'finding-0001', + entityType: 'finding', + domain: 'findings', + title: 'CVE-2026-2222 - billing-worker [critical]', + snippet: 'Finding summary', + score: 0.98, + actions: [ + { + label: 'View Finding', + actionType: 'navigate', + route: '/security/triage?q=CVE-2026-2222', + isPrimary: true, + }, + ], + sources: ['search'], + }, + ], + synthesis: null, + diagnostics: { + ftsMatches: 12, + vectorMatches: 12, + entityCardCount: 1, + durationMs: 220, + usedVector: true, + mode: 'hybrid', + }, + }; + + component.onFocus(); + component.onQueryChange('cve'); + fixture.detectChanges(); + + await new Promise((resolve) => setTimeout(resolve, 250)); + pendingResponse$.next(response); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + + expect(component.isLoading()).toBeFalse(); + expect(fixture.nativeElement.querySelector('.search__loading')).toBeNull(); + expect(fixture.nativeElement.querySelector('.search__empty')).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('CVE-2026-2222 - billing-worker [critical]'); + }); +}); 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 ae238d68f..2b282b79a 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 @@ -1521,7 +1521,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { if (value.trim().length === 0) { this.refreshSuggestionViability(); } - this.searchTerms$.next(value.trim()); + this.queueSearch(value); } onKeydown(event: KeyboardEvent): void { @@ -1683,7 +1683,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { queryHint: query, }); this.query.set(query); - this.searchTerms$.next(query.trim()); + this.queueSearch(query); this.keepSearchSurfaceOpen(); } @@ -1703,7 +1703,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { queryHint: example, }); this.query.set(example); - this.searchTerms$.next(example.trim()); + this.queueSearch(example); this.keepSearchSurfaceOpen(); } @@ -1719,7 +1719,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { queryHint: query, }); this.query.set(query); - this.searchTerms$.next(query.trim()); + this.queueSearch(query); this.keepSearchSurfaceOpen(); } @@ -1730,7 +1730,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { queryHint: query, }); this.query.set(query); - this.searchTerms$.next(query.trim()); + this.queueSearch(query); this.keepSearchSurfaceOpen(); } @@ -1741,7 +1741,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { queryHint: text, }); this.query.set(text); - this.searchTerms$.next(text.trim()); + this.queueSearch(text); this.keepSearchSurfaceOpen(); } @@ -1979,11 +1979,19 @@ export class GlobalSearchComponent implements OnInit, OnDestroy { domain: context.domain, entityKey: context.entityKey, }); - this.searchTerms$.next(query); + this.queueSearch(query); setTimeout(() => this.searchInputRef?.nativeElement?.focus(), 0); } + private queueSearch(value: string): void { + const normalized = value.trim(); + this.searchResponse.set(null); + this.expandedCardKey.set(null); + this.isLoading.set(normalized.length > 0); + this.searchTerms$.next(normalized); + } + private buildAmbientSnapshot() { return this.ambientContext.buildAmbientContext({ visibleEntityKeys: this.filteredCards().map((card) => card.entityKey), diff --git a/src/Web/StellaOps.Web/src/app/layout/global-search/search-route-matrix.spec.ts b/src/Web/StellaOps.Web/src/app/layout/global-search/search-route-matrix.spec.ts new file mode 100644 index 000000000..4ab5b12dc --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/layout/global-search/search-route-matrix.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { SEARCH_ACTION_ROUTE_MATRIX, normalizeSearchActionRoute } from './search-route-matrix'; + +describe('normalizeSearchActionRoute', () => { + it('normalizes the documented route matrix', () => { + for (const entry of SEARCH_ACTION_ROUTE_MATRIX) { + expect(normalizeSearchActionRoute(entry.sourceRoute)).toBe(entry.expectedRoute); + } + }); + + it('preserves unknown external targets', () => { + expect(normalizeSearchActionRoute('https://example.invalid/docs')).toBe('https://example.invalid/docs'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/layout/global-search/search-route-matrix.ts b/src/Web/StellaOps.Web/src/app/layout/global-search/search-route-matrix.ts index f0515d4ba..fb5118c8c 100644 --- a/src/Web/StellaOps.Web/src/app/layout/global-search/search-route-matrix.ts +++ b/src/Web/StellaOps.Web/src/app/layout/global-search/search-route-matrix.ts @@ -1,3 +1,5 @@ +import { buildDocsRoute, parseDocsUrl } from '../../core/navigation/docs-route'; + export interface SearchRouteMatrixEntry { domain: 'knowledge' | 'findings' | 'policy' | 'vex' | 'platform'; sourceRoute: string; @@ -11,6 +13,11 @@ export const SEARCH_ACTION_ROUTE_MATRIX: ReadonlyArray = sourceRoute: '/docs/modules/platform/architecture-overview.md#release-flow', expectedRoute: '/docs/modules/platform/architecture-overview.md#release-flow', }, + { + domain: 'knowledge', + sourceRoute: '/docs/docs%2Fmodules%2Fadvisory-ai%2Fchat-interface.md#vulnerability-investigation', + expectedRoute: '/docs/modules/advisory-ai/chat-interface.md#vulnerability-investigation', + }, { domain: 'findings', sourceRoute: '/triage/findings/fnd-123', @@ -61,6 +68,11 @@ export function normalizeSearchActionRoute(route: string): string { const pathname = parsedUrl.pathname; + if (pathname.startsWith('/docs/')) { + const docsTarget = parseDocsUrl(`${pathname}${parsedUrl.hash}`); + return buildDocsRoute(docsTarget.path, docsTarget.anchor); + } + if (pathname.startsWith('/triage/findings/')) { parsedUrl.pathname = `/security/findings/${pathname.substring('/triage/findings/'.length)}`; } else if (pathname.startsWith('/vex-hub/')) {