Complete self-serve search rollout

This commit is contained in:
master
2026-03-08 08:50:38 +02:00
parent ac22ee3ce2
commit 80257a4538
13 changed files with 1219 additions and 58 deletions

View File

@@ -34,6 +34,7 @@ import { AmbientContextService } from '../../core/services/ambient-context.servi
import { SearchChatContextService } from '../../core/services/search-chat-context.service';
import { SearchAssistantDrawerService } from '../../core/services/search-assistant-drawer.service';
import { I18nService } from '../../core/i18n';
import { TelemetryClient } from '../../core/telemetry/telemetry.client';
import { normalizeSearchActionRoute } from './search-route-matrix';
type SearchSuggestionView = {
@@ -50,6 +51,12 @@ type SearchStarterView = {
kind: 'question' | 'suggestion';
};
type SuggestedExecutionSource = 'starter' | 'question' | 'answer-next' | 'did-you-mean';
type SearchGapJourney = {
route: string;
queryKey: string;
status: 'clarify' | 'insufficient';
reformulationCount: number;
};
type SearchContextPanelView = {
title: string;
description: string;
@@ -923,6 +930,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
private readonly searchChatContext = inject(SearchChatContextService);
private readonly assistantDrawer = inject(SearchAssistantDrawerService);
private readonly i18n = inject(I18nService);
private readonly telemetry = inject(TelemetryClient);
private readonly destroy$ = new Subject<void>();
private readonly searchTerms$ = new Subject<string>();
private readonly recentSearchStorageKey = 'stella-successful-searches-v3';
@@ -932,6 +940,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
| { query: string; source: SuggestedExecutionSource }
| null = null;
private wasDegradedMode = false;
private activeGapJourney: SearchGapJourney | null = null;
private escapeCount = 0;
private placeholderRotationHandle: ReturnType<typeof setInterval> | null = null;
private blurHideHandle: ReturnType<typeof setTimeout> | null = null;
@@ -1306,6 +1315,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
)
.subscribe(() => {
this.consumeChatToSearchContext();
this.activeGapJourney = null;
this.clearSuppressedStarterQueries();
if (this.isFocused()) {
this.refreshSuggestionViability();
@@ -1368,6 +1378,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
);
}
this.reconcileSuggestedExecution(response);
this.emitSelfServeGapTelemetry(response);
// Sprint 106 / G6: Emit search analytics events
this.emitSearchAnalytics(response);
@@ -2038,6 +2049,96 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
});
}
private emitSelfServeGapTelemetry(response: UnifiedSearchResponse): void {
const query = response.query.trim();
if (!query) {
return;
}
const route = this.router.url;
const queryKey = this.normalizeQueryKey(query);
const status = this.inferAnswerStatus(response);
const queryTokenCount = query.split(/\s+/).filter((token) => token.length > 0).length;
const coverage = response.coverage;
const currentScopeDomain = coverage?.currentScopeDomain ?? null;
const currentScopeCoverage = coverage?.domains.find((domain) => domain.isCurrentScope)
?? coverage?.domains.find((domain) => domain.domain === currentScopeDomain)
?? null;
if (status === 'grounded') {
if (this.activeGapJourney && this.activeGapJourney.route === route) {
this.telemetry.emit('search_self_serve_recovery', {
route,
previousStatus: this.activeGapJourney.status,
reformulationCount: this.activeGapJourney.reformulationCount,
queryLength: query.length,
queryTokenCount,
cardCount: response.cards.length,
overflowCount: response.overflow?.cards.length ?? 0,
currentScopeDomain,
});
}
this.activeGapJourney = null;
return;
}
let reformulationCount = 0;
if (this.activeGapJourney && this.activeGapJourney.route === route) {
reformulationCount = this.activeGapJourney.reformulationCount;
if (this.activeGapJourney.queryKey !== queryKey) {
reformulationCount += 1;
this.telemetry.emit('search_self_serve_reformulation', {
route,
previousStatus: this.activeGapJourney.status,
nextStatus: status,
reformulationCount,
queryLength: query.length,
queryTokenCount,
});
}
}
this.telemetry.emit('search_self_serve_gap', {
route,
answerStatus: status,
reformulationCount,
queryLength: query.length,
queryTokenCount,
currentScopeDomain,
currentScopeWeighted: coverage?.currentScopeWeighted === true,
currentScopeCandidateCount: currentScopeCoverage?.candidateCount ?? 0,
currentScopeVisibleCount: currentScopeCoverage?.visibleCardCount ?? 0,
cardCount: response.cards.length,
overflowCount: response.overflow?.cards.length ?? 0,
questionCount: status === 'clarify'
? this.clarifyingQuestions().length
: this.commonQuestions().length,
viableSuggestionCount: this.contextualSuggestions().length,
});
this.activeGapJourney = {
route,
queryKey,
status,
reformulationCount,
};
}
private inferAnswerStatus(response: UnifiedSearchResponse): SearchAnswerView['status'] {
if (response.contextAnswer) {
return response.contextAnswer.status;
}
if (this.hasSearchEvidence(response)) {
return 'grounded';
}
return this.clarifyingQuestions().length > 0
? 'clarify'
: 'insufficient';
}
private buildGroundedAnswerSummary(response: UnifiedSearchResponse): string {
const synthesisSummary = response.synthesis?.summary?.trim();
if (synthesisSummary) {
@@ -2243,6 +2344,13 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
if (!this.hasSearchEvidence(response)) {
this.suppressStarterQuery(response.query);
this.telemetry.emit('search_self_serve_suggestion_suppressed', {
route: this.router.url,
source: pending.source,
answerStatus: this.inferAnswerStatus(response),
queryLength: response.query.trim().length,
queryTokenCount: response.query.split(/\s+/).filter((token) => token.length > 0).length,
});
}
this.pendingSuggestedExecution = null;

View File

@@ -202,4 +202,38 @@ describe('AmbientContextService', () => {
expect(questions).toContain('Which rule, environment, or control should I narrow this to?');
expect(questions).toContain('Do you want recent failures, exceptions, or promotion impact?');
});
it('returns timeline self-serve questions after navigating to the timeline route', () => {
const service = TestBed.inject(AmbientContextService);
router.url = '/ops/timeline';
events.next(new NavigationEnd(1, '/ops/timeline', '/ops/timeline'));
expect(service.currentDomain()).toBe('timeline');
const commonQuestions = service.getCommonQuestions().map((item) => item.fallback);
const clarifyingQuestions = service.getClarifyingQuestions().map((item) => item.fallback);
expect(commonQuestions).toContain('What changed before this incident?');
expect(commonQuestions).toContain('Which release introduced this risk?');
expect(clarifyingQuestions).toContain('Which deployment, incident, or time window should I narrow this to?');
expect(clarifyingQuestions).toContain('Do you want causes, impacts, or follow-up events?');
});
it('returns release-control self-serve questions for release routes without forcing a search domain', () => {
const service = TestBed.inject(AmbientContextService);
router.url = '/releases/deployments';
events.next(new NavigationEnd(1, '/releases/deployments', '/releases/deployments'));
expect(service.currentDomain()).toBeNull();
const commonQuestions = service.getCommonQuestions().map((item) => item.fallback);
const clarifyingQuestions = service.getClarifyingQuestions().map((item) => item.fallback);
const panel = service.getSearchContextPanel();
expect(panel?.titleFallback).toBe('Release control');
expect(commonQuestions).toContain('What blocked this promotion?');
expect(commonQuestions).toContain('Which approvals are missing?');
expect(clarifyingQuestions).toContain('Which environment or release should I narrow this to?');
expect(clarifyingQuestions).toContain('Do you want blockers, approvals, or policy impact?');
});
});

View File

@@ -7,6 +7,7 @@ import { UnifiedSearchClient } from '../../app/core/api/unified-search.client';
import { AmbientContextService } from '../../app/core/services/ambient-context.service';
import { SearchAssistantDrawerService } from '../../app/core/services/search-assistant-drawer.service';
import { SearchChatContextService } from '../../app/core/services/search-chat-context.service';
import { TelemetryClient } from '../../app/core/telemetry/telemetry.client';
import { I18nService } from '../../app/core/i18n';
import { GlobalSearchComponent } from '../../app/layout/global-search/global-search.component';
@@ -19,6 +20,7 @@ describe('GlobalSearchComponent', () => {
let router: { url: string; events: Subject<unknown>; navigateByUrl: jasmine.Spy; navigate: jasmine.Spy };
let searchChatContext: jasmine.SpyObj<SearchChatContextService>;
let assistantDrawer: jasmine.SpyObj<SearchAssistantDrawerService>;
let telemetry: jasmine.SpyObj<TelemetryClient>;
beforeEach(async () => {
localStorage.clear();
@@ -182,6 +184,7 @@ describe('GlobalSearchComponent', () => {
'open',
'close',
]) as jasmine.SpyObj<SearchAssistantDrawerService>;
telemetry = jasmine.createSpyObj('TelemetryClient', ['emit']) as jasmine.SpyObj<TelemetryClient>;
await TestBed.configureTestingModule({
imports: [GlobalSearchComponent],
@@ -197,6 +200,7 @@ describe('GlobalSearchComponent', () => {
},
{ provide: SearchChatContextService, useValue: searchChatContext },
{ provide: SearchAssistantDrawerService, useValue: assistantDrawer },
{ provide: TelemetryClient, useValue: telemetry },
],
}).compileComponents();
@@ -610,6 +614,153 @@ describe('GlobalSearchComponent', () => {
expect(starterButtons).not.toContain('How do I deploy?');
});
it('emits optional self-serve gap, reformulation, and recovery telemetry markers', async () => {
searchClient.search.and.returnValues(
of({
query: 'mystery issue',
topK: 10,
cards: [],
synthesis: null,
coverage: {
currentScopeDomain: 'findings',
currentScopeWeighted: true,
domains: [
{
domain: 'findings',
candidateCount: 0,
visibleCardCount: 0,
topScore: 0,
isCurrentScope: true,
hasVisibleResults: false,
},
],
},
diagnostics: {
ftsMatches: 0,
vectorMatches: 0,
entityCardCount: 0,
durationMs: 2,
usedVector: false,
mode: 'fts-only',
},
}),
of({
query: 'still unclear',
topK: 10,
cards: [],
synthesis: null,
coverage: {
currentScopeDomain: 'findings',
currentScopeWeighted: true,
domains: [
{
domain: 'findings',
candidateCount: 0,
visibleCardCount: 0,
topScore: 0,
isCurrentScope: true,
hasVisibleResults: false,
},
],
},
diagnostics: {
ftsMatches: 0,
vectorMatches: 0,
entityCardCount: 0,
durationMs: 2,
usedVector: false,
mode: 'fts-only',
},
}),
of({
query: 'critical findings',
topK: 10,
cards: [createCard('findings', '/triage/findings/fnd-recovery')],
synthesis: null,
diagnostics: {
ftsMatches: 1,
vectorMatches: 0,
entityCardCount: 1,
durationMs: 2,
usedVector: false,
mode: 'fts-only',
},
}),
);
component.onFocus();
component.onQueryChange('mystery issue');
await waitForDebounce();
component.onQueryChange('still unclear');
await waitForDebounce();
component.onQueryChange('critical findings');
await waitForDebounce();
expect(telemetry.emit).toHaveBeenCalledWith('search_self_serve_gap', jasmine.objectContaining({
route: '/security/triage',
answerStatus: 'clarify',
currentScopeDomain: 'findings',
}));
expect(telemetry.emit).toHaveBeenCalledWith('search_self_serve_reformulation', jasmine.objectContaining({
route: '/security/triage',
previousStatus: 'clarify',
nextStatus: 'clarify',
reformulationCount: 1,
}));
expect(telemetry.emit).toHaveBeenCalledWith('search_self_serve_recovery', jasmine.objectContaining({
route: '/security/triage',
previousStatus: 'clarify',
reformulationCount: 1,
cardCount: 1,
}));
});
it('emits suggestion suppression telemetry when a suggested query dead-ends', async () => {
searchClient.search.and.returnValues(
of({
query: 'How do I deploy?',
topK: 10,
cards: [],
synthesis: null,
diagnostics: {
ftsMatches: 0,
vectorMatches: 0,
entityCardCount: 0,
durationMs: 2,
usedVector: false,
mode: 'fts-only',
},
}),
of({
query: '',
topK: 10,
cards: [],
synthesis: null,
diagnostics: {
ftsMatches: 0,
vectorMatches: 0,
entityCardCount: 0,
durationMs: 0,
usedVector: false,
mode: 'fts-only',
},
}),
);
component.onFocus();
fixture.detectChanges();
component.applyStarterQuery({ query: 'How do I deploy?', kind: 'suggestion' });
await waitForDebounce();
expect(telemetry.emit).toHaveBeenCalledWith('search_self_serve_suggestion_suppressed', jasmine.objectContaining({
route: '/security/triage',
source: 'starter',
answerStatus: 'clarify',
}));
});
it('suppresses contextual chips marked non-viable by backend suggestion evaluation', () => {
searchClient.evaluateSuggestions.and.returnValue(of({
suggestions: [

View File

@@ -113,7 +113,7 @@ test.describe('Unified Search - Contextual Suggestions', () => {
await waitForEntityCards(page, 1);
await page.locator('app-entity-card').first().click();
await expect(page).toHaveURL(/\/security\/triage\?q=CVE-2024-21626/i);
await expect(page).toHaveURL(/\/security\/triage\?.*q=CVE-2024-21626/i);
const searchInput = page.locator('app-global-search input[type="text"]');
await searchInput.focus();

View File

@@ -3,7 +3,7 @@
// Shared mock data & helpers for all unified search e2e test suites.
// -----------------------------------------------------------------------------
import type { Page } from '@playwright/test';
import type { Page, Route } from '@playwright/test';
import { policyAuthorSession } from '../../src/app/testing';
@@ -71,6 +71,21 @@ export const shellSession = {
// ---------------------------------------------------------------------------
export async function setupBasicMocks(page: Page) {
const jsonStubUnlessDocument = (defaultGetBody: unknown = []): ((route: Route) => Promise<void>) => {
return async (route) => {
if (route.request().resourceType() === 'document') {
await route.fallback();
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(route.request().method() === 'GET' ? defaultGetBody : {}),
});
};
};
page.on('console', (message) => {
if (message.type() !== 'error') return;
const text = message.text();
@@ -115,9 +130,20 @@ export async function setupBasicMocks(page: Page) {
return route.fulfill({ status: 400, body: 'blocked' });
});
await page.route('**/api/**', jsonStubUnlessDocument());
await page.route('**/gateway/**', jsonStubUnlessDocument());
await page.route('**/policy/**', jsonStubUnlessDocument());
await page.route('**/scanner/**', jsonStubUnlessDocument());
await page.route('**/concelier/**', jsonStubUnlessDocument());
await page.route('**/attestor/**', jsonStubUnlessDocument());
await page.route('**/api/v1/search/suggestions/evaluate', async (route) => {
const body = (route.request().postDataJSON() as { queries?: string[] } | null) ?? {};
const body = (route.request().postDataJSON() as {
queries?: string[];
ambient?: { currentRoute?: string };
} | null) ?? {};
const queries = body.queries ?? [];
const currentScopeDomain = resolveMockScopeDomain(body.ambient?.currentRoute);
return route.fulfill({
status: 200,
contentType: 'application/json',
@@ -128,15 +154,15 @@ export async function setupBasicMocks(page: Page) {
status: 'grounded',
code: 'retrieved_scope_weighted_evidence',
cardCount: 1,
leadingDomain: 'findings',
leadingDomain: currentScopeDomain,
reason: 'Evidence is available for this suggestion.',
})),
coverage: {
currentScopeDomain: 'findings',
currentScopeDomain,
currentScopeWeighted: true,
domains: [
{
domain: 'findings',
domain: currentScopeDomain,
candidateCount: Math.max(1, queries.length),
visibleCardCount: Math.max(1, queries.length),
topScore: 0.9,
@@ -509,6 +535,68 @@ export function policyCard(opts: {
};
}
export function timelineCard(opts: {
eventId: string;
title: string;
snippet: string;
score?: number;
}): CardFixture {
return {
entityKey: `timeline:${opts.eventId}`,
entityType: 'ops_event',
domain: 'timeline',
title: opts.title,
snippet: opts.snippet,
score: opts.score ?? 0.81,
actions: [
{
label: 'Open event',
actionType: 'navigate',
route: `/ops/timeline/${opts.eventId}`,
isPrimary: true,
},
{
label: 'Open audit trail',
actionType: 'navigate',
route: `/evidence/audit-log/events/${opts.eventId}`,
isPrimary: false,
},
],
sources: ['timeline'],
};
}
export function releaseCard(opts: {
releaseId: string;
title: string;
snippet: string;
score?: number;
}): CardFixture {
return {
entityKey: `release:${opts.releaseId}`,
entityType: 'platform_entity',
domain: 'platform',
title: opts.title,
snippet: opts.snippet,
score: opts.score ?? 0.84,
actions: [
{
label: 'Open release',
actionType: 'navigate',
route: `/releases/${opts.releaseId}`,
isPrimary: true,
},
{
label: 'Open approvals',
actionType: 'navigate',
route: '/releases/approvals',
isPrimary: false,
},
],
sources: ['platform'],
};
}
export function docsCard(opts: {
docPath: string;
title: string;
@@ -550,3 +638,45 @@ export function apiCard(opts: {
sources: ['knowledge'],
};
}
function resolveMockScopeDomain(currentRoute?: string): string {
const normalizedRoute = (currentRoute ?? '').trim().toLowerCase();
if (normalizedRoute.includes('/ops/policy/vex')
|| normalizedRoute.includes('/security/advisories-vex')
|| normalizedRoute.includes('/vex-hub')) {
return 'vex';
}
if (normalizedRoute.includes('/ops/policy')) {
return 'policy';
}
if (normalizedRoute.includes('/ops/operations/doctor')
|| normalizedRoute.includes('/ops/operations/system-health')) {
return 'knowledge';
}
if (normalizedRoute.includes('/ops/timeline')
|| normalizedRoute.includes('/evidence/audit-log')
|| normalizedRoute.includes('/audit')) {
return 'timeline';
}
if (normalizedRoute.includes('/releases')
|| normalizedRoute.includes('/mission-control')) {
return 'platform';
}
if (normalizedRoute.includes('/ops/graph')
|| normalizedRoute.includes('/security/reach')) {
return 'graph';
}
if (normalizedRoute.includes('/ops/operations/jobs')
|| normalizedRoute.includes('/ops/operations/scheduler')) {
return 'ops_memory';
}
return 'findings';
}

View File

@@ -0,0 +1,701 @@
import { expect, test, type Page } from '@playwright/test';
import {
buildResponse,
emptyResponse,
findingCard,
policyCard,
releaseCard,
setupAuthenticatedSession,
setupBasicMocks,
timelineCard,
typeInSearch,
waitForEntityCards,
waitForResults,
} from './unified-search-fixtures';
const doctorConnectivityCard = {
entityKey: 'doctor:check.core.db.connectivity',
entityType: 'doctor',
domain: 'knowledge',
title: 'PostgreSQL connectivity',
snippet: 'Database connectivity is degraded and blocks the current release readiness check.',
score: 0.95,
actions: [
{
label: 'Open check',
actionType: 'navigate',
route: '/ops/operations/doctor?check=check.core.db.connectivity',
isPrimary: true,
},
],
sources: ['knowledge'],
metadata: {
checkId: 'check.core.db.connectivity',
},
};
const findingsGroundedResponse = buildResponse(
'What evidence blocks this release?',
[
findingCard({
cveId: 'CVE-2024-21626',
title: 'CVE-2024-21626 in api-gateway',
snippet: 'A reachable critical vulnerability remains unresolved in the active production workload.',
severity: 'critical',
}),
],
undefined,
{
contextAnswer: {
status: 'grounded',
code: 'retrieved_scope_weighted_evidence',
summary: 'A reachable critical finding in api-gateway is the strongest release blocker on this page.',
reason: 'Findings evidence ranked ahead of related policy matches.',
evidence: 'Grounded in 2 sources across Findings and Policy.',
citations: [
{
entityKey: 'cve:CVE-2024-21626',
title: 'CVE-2024-21626 in api-gateway',
domain: 'findings',
},
],
questions: [
{
query: 'What is the safest remediation path?',
kind: 'follow_up',
},
],
},
overflow: {
currentScopeDomain: 'findings',
reason: 'Policy impact stays relevant but secondary to the current finding.',
cards: [
policyCard({
ruleId: 'POL-118',
title: 'POL-118 release blocker',
snippet: 'Production rollout remains blocked while this finding is unresolved.',
}),
],
},
coverage: {
currentScopeDomain: 'findings',
currentScopeWeighted: true,
domains: [
{
domain: 'findings',
candidateCount: 2,
visibleCardCount: 1,
topScore: 0.95,
isCurrentScope: true,
hasVisibleResults: true,
},
{
domain: 'policy',
candidateCount: 1,
visibleCardCount: 1,
topScore: 0.73,
isCurrentScope: false,
hasVisibleResults: true,
},
],
},
},
);
const policyGroundedResponse = buildResponse(
'Why is this gate failing?',
[
policyCard({
ruleId: 'POL-118',
title: 'POL-118 release blocker',
snippet: 'The gate is failing because reachable critical findings still block production promotion.',
}),
],
undefined,
{
contextAnswer: {
status: 'grounded',
code: 'retrieved_scope_weighted_evidence',
summary: 'The release gate is failing because production still has reachable critical findings with no approved exception.',
reason: 'Policy evidence matched directly in the current route.',
evidence: 'Grounded in 2 sources across Policy and Findings.',
citations: [
{
entityKey: 'policy:POL-118',
title: 'POL-118 release blocker',
domain: 'policy',
},
],
questions: [
{
query: 'What findings are impacted by this rule?',
kind: 'follow_up',
},
],
},
coverage: {
currentScopeDomain: 'policy',
currentScopeWeighted: true,
domains: [
{
domain: 'policy',
candidateCount: 2,
visibleCardCount: 1,
topScore: 0.94,
isCurrentScope: true,
hasVisibleResults: true,
},
],
},
},
);
const policyStarterResponse = buildResponse(
'failing policy gates',
[
policyCard({
ruleId: 'POL-118',
title: 'POL-118 release blocker',
snippet: 'Current policy blockers remain concentrated around reachable production findings.',
}),
],
undefined,
{
contextAnswer: {
status: 'grounded',
code: 'retrieved_scope_weighted_evidence',
summary: 'Failing policy gates remain concentrated in the current policy workspace.',
reason: 'Starter search stayed inside policy scope first.',
evidence: 'Grounded in 1 source across Policy.',
citations: [
{
entityKey: 'policy:POL-118',
title: 'POL-118 release blocker',
domain: 'policy',
},
],
},
coverage: {
currentScopeDomain: 'policy',
currentScopeWeighted: true,
domains: [
{
domain: 'policy',
candidateCount: 1,
visibleCardCount: 1,
topScore: 0.9,
isCurrentScope: true,
hasVisibleResults: true,
},
],
},
},
);
const doctorGroundedResponse = buildResponse(
'database connectivity',
[doctorConnectivityCard],
undefined,
{
contextAnswer: {
status: 'grounded',
code: 'retrieved_scope_weighted_evidence',
summary: 'Database connectivity is the highest-signal release readiness blocker in the current Doctor view.',
reason: 'Doctor evidence ranked first on the current page.',
evidence: 'Grounded in 1 source across Knowledge.',
citations: [
{
entityKey: 'doctor:check.core.db.connectivity',
title: 'PostgreSQL connectivity',
domain: 'knowledge',
route: '/ops/operations/doctor?check=check.core.db.connectivity',
},
],
},
coverage: {
currentScopeDomain: 'knowledge',
currentScopeWeighted: true,
domains: [
{
domain: 'knowledge',
candidateCount: 2,
visibleCardCount: 1,
topScore: 0.95,
isCurrentScope: true,
hasVisibleResults: true,
},
],
},
},
);
const timelineGroundedResponse = buildResponse(
'Which deployment, incident, or time window should I narrow this to?',
[
timelineCard({
eventId: 'incident-db-spike',
title: 'Database latency spike before promotion',
snippet: 'A deployment preceded the incident and introduced the latency spike that now blocks the rollout.',
}),
],
undefined,
{
contextAnswer: {
status: 'grounded',
code: 'retrieved_scope_weighted_evidence',
summary: 'The timeline shows a deployment immediately before the incident that introduced the blocking latency spike.',
reason: 'Timeline evidence became grounded once the time window was narrowed.',
evidence: 'Grounded in 1 source across Timeline.',
citations: [
{
entityKey: 'timeline:incident-db-spike',
title: 'Database latency spike before promotion',
domain: 'timeline',
},
],
},
coverage: {
currentScopeDomain: 'timeline',
currentScopeWeighted: true,
domains: [
{
domain: 'timeline',
candidateCount: 1,
visibleCardCount: 1,
topScore: 0.9,
isCurrentScope: true,
hasVisibleResults: true,
},
],
},
},
);
const releasesGroundedResponse = buildResponse(
'What blocked this promotion?',
[
releaseCard({
releaseId: 'rel-2026-03-08',
title: 'Release 2026.03.08',
snippet: 'Promotion is blocked by missing production approvals and a policy gate still tied to reachable findings.',
}),
],
undefined,
{
contextAnswer: {
status: 'grounded',
code: 'retrieved_scope_weighted_evidence',
summary: 'The promotion is blocked by missing approvals first, with a related policy gate also still open.',
reason: 'Release-control evidence outranked broader platform matches.',
evidence: 'Grounded in 2 sources across Platform and Policy.',
citations: [
{
entityKey: 'release:rel-2026-03-08',
title: 'Release 2026.03.08',
domain: 'platform',
route: '/releases/rel-2026-03-08',
},
],
},
overflow: {
currentScopeDomain: 'platform',
reason: 'A related policy blocker is still relevant but secondary to the current release page.',
cards: [
policyCard({
ruleId: 'POL-118',
title: 'POL-118 release blocker',
snippet: 'The same release remains tied to a critical finding with no approved exception.',
}),
],
},
coverage: {
currentScopeDomain: 'platform',
currentScopeWeighted: true,
domains: [
{
domain: 'platform',
candidateCount: 2,
visibleCardCount: 1,
topScore: 0.91,
isCurrentScope: true,
hasVisibleResults: true,
},
{
domain: 'policy',
candidateCount: 1,
visibleCardCount: 1,
topScore: 0.72,
isCurrentScope: false,
hasVisibleResults: true,
},
],
},
},
);
type PriorityRouteCheck = {
path: string;
contextTitle: RegExp;
starterText: RegExp;
};
const priorityRoutes: readonly PriorityRouteCheck[] = [
{
path: '/security/triage',
contextTitle: /findings triage/i,
starterText: /why is this exploitable in my environment\?/i,
},
{
path: '/ops/policy',
contextTitle: /policy workspace/i,
starterText: /why is this gate failing\?/i,
},
{
path: '/ops/operations/doctor',
contextTitle: /doctor diagnostics/i,
starterText: /which failing check is blocking release\?/i,
},
{
path: '/ops/timeline',
contextTitle: /timeline analysis/i,
starterText: /what changed before this incident\?/i,
},
{
path: '/releases/deployments',
contextTitle: /release control/i,
starterText: /what blocked this promotion\?/i,
},
] as const;
test.describe('Unified Search - Priority Route Self-Serve Journeys', () => {
test.beforeEach(async ({ page }) => {
await setupBasicMocks(page);
await setupAuthenticatedSession(page);
await setupDoctorPageMocks(page);
});
test('surfaces route-owned starter queries across all priority routes', async ({ page }) => {
await mockPriorityRouteSearch(page);
for (const route of priorityRoutes) {
await openRoute(page, route.path);
const searchInput = page.locator('app-global-search input[type="text"]');
await searchInput.focus();
await waitForResults(page);
await expect(page.locator('.search__context-title')).toContainText(route.contextTitle);
await expect(page.locator('[data-starter-kind]', {
hasText: route.starterText,
}).first()).toBeVisible();
}
});
test('opens deeper help from a grounded findings answer without leaving the page route', async ({ page }) => {
const capturedTurnBodies: Array<Record<string, unknown>> = [];
await mockPriorityRouteSearch(page);
await mockChatConversation(page, capturedTurnBodies);
await openRoute(page, '/security/triage');
await page.locator('app-global-search input[type="text"]').focus();
await waitForResults(page);
const blockerQuestion = page.getByRole('button', { name: 'What evidence blocks this release?' });
await blockerQuestion.evaluate((element: HTMLElement) => element.click());
await waitForResults(page);
await waitForEntityCards(page, 1);
await expect(page.locator('[data-answer-status="grounded"]')).toContainText(/strongest release blocker/i);
await page.locator('[data-answer-action="ask-ai"]').click();
await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 });
await expect.poll(() => capturedTurnBodies.length).toBeGreaterThan(0);
expect(String(capturedTurnBodies.at(-1)?.['content'] ?? '')).toMatch(/what evidence blocks this release\?/i);
expect(String(capturedTurnBodies.at(-1)?.['content'] ?? '')).toMatch(/findings triage/i);
await expect(page).toHaveURL(/\/security\/triage(\?.*)?$/i);
});
test('promotes a follow-up chip after opening a grounded doctor result', async ({ page }) => {
await mockPriorityRouteSearch(page);
await openRoute(page, '/ops/operations/doctor');
const searchInput = page.locator('app-global-search input[type="text"]');
await searchInput.focus();
await waitForResults(page);
await page.getByRole('button', { name: 'database connectivity' }).click();
await waitForResults(page);
await waitForEntityCards(page, 1);
await page.locator('app-entity-card').first().click();
await expect(page).toHaveURL(/\/ops\/operations\/doctor\?check=check\.core\.db\.connectivity/i);
await page.locator('app-global-search input[type="text"]').focus();
await waitForResults(page);
await expect(page.locator('.search__suggestions .search__chip', {
hasText: /follow up:\s*database connectivity/i,
}).first()).toBeVisible();
});
test('recovers a timeline search from clarify to grounded with the route-owned narrowing question', async ({ page }) => {
await mockPriorityRouteSearch(page);
await openRoute(page, '/ops/timeline');
await typeInSearch(page, 'spike');
await waitForResults(page);
await expect(page.locator('[data-answer-status="clarify"]')).toBeVisible();
await page.getByRole('button', { name: 'Which deployment, incident, or time window should I narrow this to?' }).click();
await waitForResults(page);
await waitForEntityCards(page, 1);
await expect(page.locator('app-global-search input[type="text"]')).toHaveValue(
'Which deployment, incident, or time window should I narrow this to?',
);
await expect(page.locator('[data-answer-status="grounded"]')).toContainText(/deployment immediately before the incident/i);
});
test('reruns a policy next-search inside the current policy route context', async ({ page }) => {
const capturedRequests: Array<Record<string, unknown>> = [];
await mockPriorityRouteSearch(page, capturedRequests);
await openRoute(page, '/ops/policy');
await page.locator('app-global-search input[type="text"]').focus();
await waitForResults(page);
await page.getByRole('button', { name: 'Why is this gate failing?' }).click();
await waitForResults(page);
await waitForEntityCards(page, 1);
const nextSearchButton = page.locator('[data-answer-next-search]').filter({
hasNotText: /follow up:/i,
}).first();
await expect(nextSearchButton).toBeVisible();
await nextSearchButton.click();
await waitForResults(page);
await waitForEntityCards(page, 1);
const rerunQuery = await page.locator('app-global-search input[type="text"]').inputValue();
expect(rerunQuery.trim().length).toBeGreaterThan(0);
await expect.poll(() =>
capturedRequests.some((request) => String(request['q'] ?? '') === rerunQuery),
).toBe(true);
await expect(page).toHaveURL(/\/ops\/policy/i);
});
test('shows overflow as a secondary section for grounded release-control answers', async ({ page }) => {
await mockPriorityRouteSearch(page);
await openRoute(page, '/releases/deployments');
await page.locator('app-global-search input[type="text"]').focus();
await waitForResults(page);
await page.getByRole('button', { name: 'What blocked this promotion?' }).click();
await waitForResults(page);
await waitForEntityCards(page, 1);
await expect(page.locator('[data-answer-status="grounded"]')).toContainText(/missing approvals first/i);
await expect(page.locator('[data-overflow-results]')).toContainText(/also relevant elsewhere/i);
await expect(page.locator('[data-overflow-results]')).toContainText(/related policy blocker is still relevant/i);
});
});
async function openRoute(page: Page, path: string): Promise<void> {
await page.goto(path);
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
}
async function mockPriorityRouteSearch(
page: Page,
capturedRequests: Array<Record<string, unknown>> = [],
): Promise<void> {
await page.route('**/search/query**', async (route) => {
const body = (route.request().postDataJSON() as Record<string, unknown> | null) ?? {};
capturedRequests.push(body);
const query = String(body['q'] ?? '').trim().toLowerCase();
const ambient = body['ambient'] as Record<string, unknown> | undefined;
const currentRoute = String(ambient?.['currentRoute'] ?? '').toLowerCase();
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(resolvePriorityRouteResponse(currentRoute, query)),
});
});
}
function resolvePriorityRouteResponse(currentRoute: string, query: string): unknown {
if (currentRoute.includes('/security/triage')) {
if (query.includes('what evidence blocks this release')) {
return findingsGroundedResponse;
}
if (query.includes('what is the safest remediation path')) {
return findingsGroundedResponse;
}
}
if (currentRoute.includes('/ops/policy')) {
if (query.includes('why is this gate failing')) {
return policyGroundedResponse;
}
if (query.includes('failing policy gates')) {
return policyStarterResponse;
}
if (query.includes('policy exceptions') || query.includes('production deny rules')) {
return policyStarterResponse;
}
}
if (currentRoute.includes('/ops/operations/doctor')) {
if (query.includes('database connectivity')) {
return doctorGroundedResponse;
}
}
if (currentRoute.includes('/ops/timeline')) {
if (query.includes('which deployment, incident, or time window should i narrow this to')) {
return timelineGroundedResponse;
}
if (query.includes('spike')) {
return emptyResponse('spike');
}
}
if (currentRoute.includes('/releases')) {
if (query.includes('what blocked this promotion')) {
return releasesGroundedResponse;
}
}
return emptyResponse(query);
}
async function mockChatConversation(
page: Page,
capturedTurnBodies: Array<Record<string, unknown>>,
): Promise<void> {
await page.route('**/api/v1/advisory-ai/conversations', async (route) => {
if (route.request().method() !== 'POST') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]),
});
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
conversationId: 'conv-priority-routes-1',
tenantId: 'test-tenant',
userId: 'tester',
context: {},
turns: [],
createdAt: '2026-03-08T00:00:00.000Z',
updatedAt: '2026-03-08T00:00:00.000Z',
}),
});
});
await page.route('**/api/v1/advisory-ai/conversations/*/turns', async (route) => {
if (route.request().method() !== 'POST') {
return route.continue();
}
capturedTurnBodies.push((route.request().postDataJSON() as Record<string, unknown> | null) ?? {});
const events = [
'event: progress',
'data: {"stage":"searching"}',
'',
'event: token',
'data: {"content":"I can expand the current grounded answer and tell you the safest next step."}',
'',
'event: done',
'data: {"turnId":"turn-priority-routes-1","groundingScore":0.94}',
'',
].join('\n');
return route.fulfill({
status: 200,
headers: {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache',
},
body: events,
});
});
}
async function setupDoctorPageMocks(page: Page): Promise<void> {
await page.route('**/doctor/api/v1/doctor/plugins**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
plugins: [
{
pluginId: 'integration.registry',
displayName: 'Registry Integration',
category: 'integration',
version: '1.0.0',
checkCount: 3,
},
],
total: 1,
}),
}),
);
await page.route('**/doctor/api/v1/doctor/checks**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
checks: [
{
checkId: 'check.core.db.connectivity',
name: 'Database connectivity',
description: 'Verify PostgreSQL connectivity for release readiness.',
pluginId: 'integration.registry',
category: 'integration',
defaultSeverity: 'fail',
tags: ['database', 'postgresql'],
estimatedDurationMs: 5000,
},
],
total: 1,
}),
}),
);
await page.route('**/doctor/api/v1/doctor/run', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ runId: 'dr-priority-route-001' }),
}),
);
await page.route('**/doctor/api/v1/doctor/run/**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
runId: 'dr-priority-route-001',
status: 'completed',
startedAt: '2026-03-08T08:00:00Z',
completedAt: '2026-03-08T08:00:06Z',
durationMs: 6000,
summary: { passed: 0, info: 0, warnings: 0, failed: 1, skipped: 0, total: 1 },
overallSeverity: 'fail',
results: [],
}),
}),
);
}