Repair search result routing and advisory query ranking
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, string> 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<EntityMention>? 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<EntityMention>? 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<string, double>
|
||||
{
|
||||
["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<string, (string ChunkId, int Rank, KnowledgeChunkRow Row)>(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<string, double>
|
||||
{
|
||||
["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<string, (string ChunkId, int Rank, KnowledgeChunkRow Row)>(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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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<GlobalSearchComponent>;
|
||||
let component: GlobalSearchComponent;
|
||||
let searchClient: jasmine.SpyObj<UnifiedSearchClient>;
|
||||
let pendingResponse$: Subject<UnifiedSearchResponse>;
|
||||
let chatToSearchRequested$: Subject<void>;
|
||||
|
||||
beforeEach(async () => {
|
||||
pendingResponse$ = new Subject<UnifiedSearchResponse>();
|
||||
chatToSearchRequested$ = new Subject<void>();
|
||||
searchClient = jasmine.createSpyObj(
|
||||
'UnifiedSearchClient',
|
||||
['search', 'submitFeedback', 'getHistory', 'recordAnalytics', 'evaluateSuggestions', 'clearHistory'],
|
||||
) as jasmine.SpyObj<UnifiedSearchClient>;
|
||||
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<AmbientContextService>;
|
||||
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<I18nService>;
|
||||
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]');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<SearchRouteMatrixEntry> =
|
||||
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/')) {
|
||||
|
||||
Reference in New Issue
Block a user