Complete self-serve search rollout
This commit is contained in:
@@ -31,9 +31,9 @@ Task description:
|
||||
- Preserve backward compatibility so older Web clients can ignore the new fields safely.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Unified search response contract includes contextual answer and fallback metadata.
|
||||
- [ ] Existing callers remain compatible when new fields are absent.
|
||||
- [ ] API docs describe the new answer payload.
|
||||
- [x] Unified search response contract includes contextual answer and fallback metadata.
|
||||
- [x] Existing callers remain compatible when new fields are absent.
|
||||
- [x] API docs describe the new answer payload.
|
||||
|
||||
### AI-SELF-002 - Grounding and fallback policy
|
||||
Status: DONE
|
||||
@@ -47,9 +47,9 @@ Task description:
|
||||
- Make the reasoning explicit in payload fields so UI does not have to guess why a state was chosen.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Grounding thresholds are explicit and deterministic.
|
||||
- [ ] Clarification and insufficient-evidence reasons are returned in the payload.
|
||||
- [ ] Synthesis cannot silently masquerade as a grounded answer without sufficient evidence.
|
||||
- [x] Grounding thresholds are explicit and deterministic.
|
||||
- [x] Clarification and insufficient-evidence reasons are returned in the payload.
|
||||
- [x] Synthesis cannot silently masquerade as a grounded answer without sufficient evidence.
|
||||
|
||||
### AI-SELF-003 - Follow-up question and clarification generation
|
||||
Status: DONE
|
||||
@@ -60,9 +60,9 @@ Task description:
|
||||
- Prefer actionable operator questions over generic reformulations.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Payload includes bounded follow-up question arrays.
|
||||
- [ ] Generation uses page/domain context and query intent deterministically.
|
||||
- [ ] Ambiguous queries return clarifying prompts instead of a blank answer slot.
|
||||
- [x] Payload includes bounded follow-up question arrays.
|
||||
- [x] Generation uses page/domain context and query intent deterministically.
|
||||
- [x] Ambiguous queries return clarifying prompts instead of a blank answer slot.
|
||||
|
||||
### AI-SELF-004 - Self-serve telemetry and gap surfacing
|
||||
Status: DONE
|
||||
@@ -85,9 +85,9 @@ Task description:
|
||||
- Add focused integration tests to the AdvisoryAI test project that exercise grounded, clarify, and insufficient-evidence answer states through the real endpoint contract.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Tests target the specific AdvisoryAI `.csproj`, not a solution filter.
|
||||
- [ ] Assertions verify actual answer-state payload content, not only success status codes.
|
||||
- [ ] Execution log records exact commands and outcomes.
|
||||
- [x] Tests target the specific AdvisoryAI `.csproj`, not a solution filter.
|
||||
- [x] Assertions verify actual answer-state payload content, not only success status codes.
|
||||
- [x] Execution log records exact commands and outcomes.
|
||||
|
||||
### AI-SELF-006 - Ingestion-backed live corpus verification
|
||||
Status: DONE
|
||||
@@ -98,9 +98,9 @@ Task description:
|
||||
- Document the exact rebuild order, required local setup, and the query paths currently covered by live verification.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Local rebuild order is explicit and exercised: `/v1/advisory-ai/index/rebuild` then `/v1/search/index/rebuild`.
|
||||
- [ ] At least one ingestion-backed query path returns a contextual answer payload from the running local service.
|
||||
- [ ] Docs and sprint log state which live routes are verified today and which routes still rely on mocks.
|
||||
- [x] Local rebuild order is explicit and exercised: `/v1/advisory-ai/index/rebuild` then `/v1/search/index/rebuild`.
|
||||
- [x] At least one ingestion-backed query path returns a contextual answer payload from the running local service.
|
||||
- [x] Docs and sprint log state which live routes are verified today and which routes still rely on mocks.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
@@ -24,7 +24,7 @@
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-ROLL-001 - Priority page contract rollout
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Developer (FE), UX
|
||||
Task description:
|
||||
@@ -37,12 +37,12 @@ Task description:
|
||||
- Ensure every priority page exposes context rail copy, common questions, and clarifying prompts.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Each priority page has explicit self-serve question definitions.
|
||||
- [ ] Empty-state and answer-state UX remain coherent across the priority routes.
|
||||
- [ ] Page ownership is documented.
|
||||
- [x] Each priority page has explicit self-serve question definitions.
|
||||
- [x] Empty-state and answer-state UX remain coherent across the priority routes.
|
||||
- [x] Page ownership is documented.
|
||||
|
||||
### FE-ROLL-002 - Guided action handoffs
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-ROLL-001
|
||||
Owners: Developer (FE), UX
|
||||
Task description:
|
||||
@@ -50,12 +50,12 @@ Task description:
|
||||
- Reduce dead ends by always pairing answers with a credible next action.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Grounded answers expose at least one next-search or Ask-AdvisoryAI path.
|
||||
- [ ] Clarification and fallback states expose page-relevant recovery actions.
|
||||
- [ ] Handoffs preserve mode and context metadata.
|
||||
- [x] Grounded answers expose at least one next-search or Ask-AdvisoryAI path.
|
||||
- [x] Clarification and fallback states expose page-relevant recovery actions.
|
||||
- [x] Handoffs preserve mode and context metadata.
|
||||
|
||||
### FE-ROLL-003 - Telemetry-driven gap review UX
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-ROLL-001
|
||||
Owners: Developer (FE), Project Manager
|
||||
Task description:
|
||||
@@ -63,21 +63,21 @@ Task description:
|
||||
- Use telemetry to tune page questions and rescue patterns instead of relying on anecdotal adjustments.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] FE surfaces can emit telemetry markers for self-serve gap analysis.
|
||||
- [ ] Docs explain how unanswered journeys feed backlog prioritization.
|
||||
- [ ] At least one UX adjustment is traceable to telemetry evidence.
|
||||
- [x] FE surfaces can emit telemetry markers for self-serve gap analysis.
|
||||
- [x] Docs explain how unanswered journeys feed backlog prioritization.
|
||||
- [x] At least one UX adjustment is traceable to telemetry evidence.
|
||||
|
||||
### FE-ROLL-004 - Full operator-journey Playwright coverage
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-ROLL-002
|
||||
Owners: Test Automation, QA
|
||||
Task description:
|
||||
- Add end-to-end Playwright suites that verify realistic self-serve journeys from landing on a page through answer, follow-up, and action handoff.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Playwright covers the priority page journeys end to end.
|
||||
- [ ] Tests verify grounded, clarify, and recovery paths.
|
||||
- [ ] Suites remain deterministic with route mocks/local fixtures for routes that do not yet have live corpus parity.
|
||||
- [x] Playwright covers the priority page journeys end to end.
|
||||
- [x] Tests verify grounded, clarify, and recovery paths.
|
||||
- [x] Suites remain deterministic with route mocks/local fixtures for routes that do not yet have live corpus parity.
|
||||
|
||||
### FE-ROLL-006 - Live ingested-corpus search verification
|
||||
Status: DONE
|
||||
@@ -93,16 +93,16 @@ Completion criteria:
|
||||
- [ ] Live verification failures feed the rollout gap backlog instead of being hidden behind route mocks.
|
||||
|
||||
### FE-ROLL-005 - Docs and rollout readiness
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-ROLL-001
|
||||
Owners: Documentation author, Project Manager
|
||||
Task description:
|
||||
- Update UI architecture and operational guidance so teams know how to adopt and test the self-serve contract.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Docs capture rollout order and page-ownership rules.
|
||||
- [ ] Sprint links and task boards reflect the rollout state.
|
||||
- [ ] Risks and mitigations are documented.
|
||||
- [x] Docs capture rollout order and page-ownership rules.
|
||||
- [x] Sprint links and task boards reflect the rollout state.
|
||||
- [x] Risks and mitigations are documented.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
@@ -110,6 +110,7 @@ Completion criteria:
|
||||
| 2026-03-07 | Sprint created to roll out the self-serve search contract after the answer-first shell and backend answer contract are defined. | Project Manager |
|
||||
| 2026-03-07 | Added explicit live-ingested verification scope so rollout evidence distinguishes mock-backed journeys from real corpus coverage. | Project Manager |
|
||||
| 2026-03-07 | Re-ran live Playwright verification for the Doctor route against a rebuilt local AdvisoryAI corpus (`unified-search-contextual-suggestions.live.e2e.spec.ts`) and confirmed automatic chips, grounded answer framing, and follow-up chips over real search data. | Test Automation |
|
||||
| 2026-03-08 | Completed priority-route rollout verification across findings, policy, doctor, timeline, and releases. Added optional `search_self_serve_*` browser telemetry markers, route-aware fixture coverage, and a dedicated Playwright operator-journey suite (`unified-search-priority-route-journeys.e2e.spec.ts`). Focused Angular coverage passed `37/37`; the combined search Playwright pack passed `22/22`. | Developer / Test Automation |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision: rollout should prioritize high-frequency operator pages before broad route coverage.
|
||||
@@ -121,6 +122,9 @@ Completion criteria:
|
||||
- Mitigation: require Playwright coverage for each high-value journey.
|
||||
- Risk: live-ingested routes can drift from mocked expectations as ingestion adapters evolve.
|
||||
- Mitigation: document route parity and keep at least one live route in the regular regression pack.
|
||||
- Decision: self-serve gap telemetry remains optional and client-side; it must never block or alter search behavior when telemetry is disabled.
|
||||
- Risk: shared-worktree frontend errors from unrelated modules can interfere with pointer-based E2E interactions on affected routes.
|
||||
- Mitigation: keep search rollout assertions scoped to the search surface and avoid changing unrelated feature files owned by other agents.
|
||||
|
||||
## Next Checkpoints
|
||||
- 2026-03-12: Priority page rollout started after answer contract freeze.
|
||||
@@ -189,23 +189,21 @@ Each feature folder builds as a **standalone route** (lazy loaded). All HTTP sha
|
||||
|
||||
### 3.13 Global Search and Assistant Bridge
|
||||
|
||||
* **Search -> assistant handoff**: result cards and synthesis panel expose `Ask AI` actions that route to `/security/triage?openChat=true` and seed chat context through `SearchChatContextService`.
|
||||
* **Assistant host**: `/security/triage` mounts `SecurityTriageChatHostComponent`, which consumes `openChat` intent deterministically and opens the chat drawer in the primary shell.
|
||||
* **Assistant -> search return**: assistant responses expose `Search for more` and `Search related` actions; these populate global search query/domain context and focus the search surface.
|
||||
* **Guided discovery empty state**: when global search is focused with an empty query, the panel renders an 8-domain guide (findings, VEX, policy, docs, API, health, operations, timeline), contextual suggestion chips, and quick actions (`Getting Started`, `Run Health Check`, `View Recent Scans`).
|
||||
* **Automatic page-open suggestions**: `AmbientContextService` tracks router navigation and updates global-search suggestion chips/placeholders automatically for every opened page without requiring manual refresh.
|
||||
* **Context rail**: empty-state search also renders a compact rail showing the current page title/description, active domain scope, and last meaningful action on the same page scope when available.
|
||||
* **Last-action follow-up suggestions**: the same service keeps a per-route scoped last action (search result open/action, Ask AI handoff, chat return actions) with deterministic TTL bounds; empty-state chips prepend a contextual `follow up: ...` suggestion when available.
|
||||
* **Strategic non-obvious suggestions**: each page scope injects at least one cross-domain guidance query (for example, findings -> policy/VEX impact, policy -> impacted findings, doctor -> release blockers) and switches to action-aware variants after meaningful user actions.
|
||||
* **Explainable chips**: suggestion queries remain short and executable, while a separate rationale line explains whether a chip comes from page defaults, recent actions, or strategic cross-domain guidance.
|
||||
* **Shared operator modes**: global search and AdvisoryAI share one persisted mode state (`Find`, `Explain`, `Act`) via `SearchExperienceModeService`; the mode changes chip ordering, Ask-AI prompts, empty-state starters, and structured next-step behavior.
|
||||
* **Answer-first search**: every non-empty search renders a visible answer panel before raw cards; the panel must resolve to `grounded`, `clarify`, or `insufficient` and never leave the operator with an unexplained blank result area.
|
||||
* **Page-owned self-serve questions**: priority pages define common questions and clarifying prompts in the shared search context registry; empty-state search uses those as "Common questions" and answer states reuse them as follow-up or clarification buttons.
|
||||
* **Zero-result rescue loop**: no-result states must expose recovery actions for broadening scope, trying a related pivot, retrying with page context, and opening AdvisoryAI reformulation with the active mode and query context preserved.
|
||||
* **Search-surface focus rule**: focus movement into controls inside the global-search surface (mode buttons, scope toggle, rescue buttons, filters) must not collapse the panel; the surface behaves like one command workspace rather than a disposable tooltip.
|
||||
* **AdvisoryAI next-step cards**: assistant responses with citations render structured cards for evidence inspection, context navigation, deeper search, and policy pivots; search-return actions must emit deterministic `chat_next_step_*` metadata back into global search.
|
||||
* **Ambient payload activation**: each global search request sends ambient context (`currentRoute`, `visibleEntityKeys`, `recentSearches`, `sessionId`, optional `lastAction`) so AdvisoryAI can apply contextual ranking/refinement.
|
||||
* **Contract governance**: page-owned chip arrays follow `docs/modules/ui/search-chip-context-contract.md`, while answer-first self-serve questions and fallback states follow `docs/modules/ui/search-self-serve-contract.md`; both are implemented in `search-context.registry.ts`.
|
||||
* **Search-first shell**: the top-bar search field is the primary operator entry. AdvisoryAI is opened from a compact secondary icon beside search or from grounded answer/result actions, not as a competing route-first workflow.
|
||||
* **Shell-level assistant drawer**: deeper-help opens in a global drawer and keeps the operator on the current page route; focus is restored back to search when the drawer closes.
|
||||
* **Assistant -> search return**: assistant responses can return the user back into global search with populated query context and deterministic `chat_*` action metadata.
|
||||
* **Zero-learning empty state**: focused empty-state search renders only current-page context, successful history, and executable starter chips/questions. Domain-teaching cards, scope toggles, and recovery panels are intentionally absent from the primary flow.
|
||||
* **Automatic page-open suggestions**: `AmbientContextService` tracks router navigation and updates starter chips/placeholders automatically for every opened page without requiring manual refresh.
|
||||
* **Context rail**: empty-state search renders the current page title plus compact tokens for scope and last meaningful action on the same page scope when available.
|
||||
* **Last-action follow-up suggestions**: the same service keeps a per-route scoped recent-action history with deterministic TTL bounds; surfaced starters can prepend `follow up: ...` chips when that improves relevance.
|
||||
* **Implicit route weighting**: global search always prefers current-page evidence first and renders cross-scope overflow as a quiet secondary section only when it materially improves the answer.
|
||||
* **Answer-first search**: every non-empty search renders a visible answer panel before raw cards; the panel resolves to `grounded`, `clarify`, or `insufficient` and never leaves the operator with a blank result area.
|
||||
* **Page-owned self-serve questions**: priority pages define common questions and clarifying prompts in the shared search context registry; empty-state search uses those as starter questions and answer states reuse them as follow-up or clarification buttons.
|
||||
* **Priority route rollout**: mocked end-to-end journeys explicitly cover findings, policy, doctor, timeline, and release-control routes; live ingestion-backed route verification remains required where corpus parity already exists.
|
||||
* **Suggestion executability gate**: contextual/page starters preflight through backend viability signals before render so dead suggestions are suppressed instead of being taught to the user.
|
||||
* **Ambient payload activation**: each global search request sends ambient context (`currentRoute`, `visibleEntityKeys`, `recentSearches`, `sessionId`, optional `lastAction`) so AdvisoryAI can apply contextual ranking and answer shaping.
|
||||
* **Contract governance**: contextual chips follow `docs/modules/ui/search-chip-context-contract.md`, while self-serve questions, rollout ownership, and fallback states follow `docs/modules/ui/search-self-serve-contract.md`; both are implemented in `search-context.registry.ts`.
|
||||
* **Optional telemetry markers**: global search may emit client-side `search_self_serve_*` markers (`gap`, `reformulation`, `recovery`, `suggestion_suppressed`) for backlog review, but search behavior must remain unchanged when telemetry is disabled or sinks fail.
|
||||
* **Fallback transparency**: when unified search drops to legacy fallback, global search displays an explicit degraded banner and emits enter/exit telemetry markers for operator visibility.
|
||||
|
||||
---
|
||||
|
||||
@@ -20,6 +20,22 @@
|
||||
- concise, operator-facing wording
|
||||
- no tenant secrets or volatile IDs in fallback copy
|
||||
|
||||
## Priority rollout ownership
|
||||
| Context ID | Route family | Page ownership rule | Verification floor |
|
||||
| --- | --- | --- | --- |
|
||||
| `findings` | `/security/triage`, `/security/findings` | Security triage pages own the findings questions and clarifiers. | Mocked Playwright journey + shared search regressions |
|
||||
| `policy` | `/ops/policy` | Policy workspace owns the questions and recovery prompts for rules, gates, and exceptions. | Mocked Playwright journey + shared search regressions |
|
||||
| `doctor` | `/ops/operations/doctor`, `/ops/operations/system-health` | Doctor pages own the health-check questions and release-readiness follow-ups. | Mocked Playwright journey + live Doctor route pack |
|
||||
| `timeline` | `/ops/timeline`, `/audit`, `/evidence/audit-log` | Timeline/Audit pages own time-window clarifiers and causal follow-ups. | Mocked Playwright journey |
|
||||
| `releases` | `/releases`, `/mission-control` | Release-control pages own approval/blocker/next-step questions. | Mocked Playwright journey |
|
||||
|
||||
## Page team checklist
|
||||
1. Add or update the route entry in `SEARCH_CONTEXT_DEFINITIONS`.
|
||||
2. Define bounded `commonQuestions[]` and `clarifyingQuestions[]`.
|
||||
3. Keep copy operator-facing; do not expose Stella internals or scoring jargon.
|
||||
4. Verify the page still works with telemetry disabled or OTLP unset.
|
||||
5. Add/update unit coverage for route-specific questions and a Playwright journey for the page family.
|
||||
|
||||
## Answer-first UX contract
|
||||
- Every non-empty search must render one visible answer state before raw results:
|
||||
- `grounded`
|
||||
@@ -40,6 +56,22 @@
|
||||
- `clarifyingQuestions[]` when no grounded answer exists
|
||||
- "Related searches" remains driven by contextual chip logic so pages do not need to define a second parallel action system.
|
||||
|
||||
## Optional telemetry hooks
|
||||
- Search may emit optional client-side telemetry markers for backlog triage:
|
||||
- `search_self_serve_gap`
|
||||
- `search_self_serve_reformulation`
|
||||
- `search_self_serve_recovery`
|
||||
- `search_self_serve_suggestion_suppressed`
|
||||
- These markers are additive only. If telemetry is disabled or the sink is unavailable, search behavior, history, and assistant handoffs must remain unchanged.
|
||||
- Marker payloads must avoid raw query text. Use route, answer state, counts, and reformulation depth instead.
|
||||
|
||||
## Gap review workflow
|
||||
1. Review repeated `search_self_serve_gap` and `search_self_serve_reformulation` events by route family.
|
||||
2. Compare them with visible no-result/clarify journeys from Playwright and operator feedback.
|
||||
3. Update the page-owned question set or starter chips for the affected route.
|
||||
4. Add or tighten a Playwright journey before marking the gap closed.
|
||||
5. Record the adjustment in the active sprint `Execution Log` so the change is traceable.
|
||||
|
||||
## Source of truth
|
||||
- Page registry and interfaces:
|
||||
- `src/Web/StellaOps.Web/src/app/core/services/search-context.registry.ts`
|
||||
@@ -55,6 +87,7 @@
|
||||
- grounded answer
|
||||
- clarify recovery
|
||||
- answer-to-AdvisoryAI handoff
|
||||
- priority-route rollout journeys (`findings`, `policy`, `doctor`, `timeline`, `releases`)
|
||||
4. Keep route and API behavior mocked/deterministic; no live network dependencies.
|
||||
|
||||
## Relationship to chip contract
|
||||
|
||||
@@ -124,6 +124,8 @@
|
||||
- Implemented from the operator-correction pass: FE search contracts no longer depend on hidden `Find / Explain / Act` metadata, starter chips wait for backend viability before rendering, `Did you mean` is the first in-panel cue under the search field, and successful recent history now uses a structured `stella-successful-searches-v3` contract that ignores legacy bare-string entries on load.
|
||||
- Implemented from the final correction pass: the primary surface now uses secondary "deeper help/details" assistant language instead of presenting a separate AdvisoryAI product, overflow results read as supporting context, and starter chips that execute to no useful result are suppressed from the current page until context changes.
|
||||
- Implemented from the final live verification pass: the supported live route matrix now covers Doctor, Security triage, Policy governance, and Advisories/VEX with corpus rebuild preflight plus end-to-end suggestion execution on ready routes.
|
||||
- Implemented from the self-serve rollout pass: mocked operator-journey coverage now explicitly covers findings, policy, doctor, timeline, and releases route families end to end, including grounded, clarify, overflow, and deeper-help handoffs.
|
||||
- Implemented from the self-serve rollout pass: optional browser telemetry markers now capture low-coverage journeys (`gap`, `reformulation`, `recovery`, `suggestion_suppressed`) without changing search behavior when telemetry is disabled.
|
||||
- Still pending from the corrective phases: an explicit client-side telemetry opt-out control if product needs a visible switch. Current behavior is already failure-tolerant when analytics endpoints or sinks are unavailable.
|
||||
|
||||
## Execution phases - operator correction pass
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user