Consolidate search into a primary entry experience
This commit is contained in:
@@ -19,7 +19,7 @@
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-ZL-001 - Search-first top-bar entry with secondary chat launcher
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Developer (FE)
|
||||
Task description:
|
||||
@@ -27,12 +27,12 @@ Task description:
|
||||
- Opening chat must inherit page context and current query when present.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Search remains the primary focus target in the header.
|
||||
- [ ] AdvisoryAI launches from a secondary icon/button beside search.
|
||||
- [ ] Existing chat handoff still works from result cards and answer panels.
|
||||
- [x] Search remains the primary focus target in the header.
|
||||
- [x] AdvisoryAI launches from a secondary icon/button beside search.
|
||||
- [x] Existing chat handoff still works from result cards and answer panels.
|
||||
|
||||
### FE-ZL-002 - Remove explicit mode/scope/recovery controls
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-ZL-001
|
||||
Owners: Developer (FE)
|
||||
Task description:
|
||||
@@ -40,13 +40,13 @@ Task description:
|
||||
- Move `Did you mean` immediately under the input.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] No explicit mode control remains in the search panel.
|
||||
- [ ] No explicit scope toggle remains in the search panel.
|
||||
- [ ] No recovery panel remains in empty-result states.
|
||||
- [ ] `Did you mean` renders directly under the input when present.
|
||||
- [x] No explicit mode control remains in the search panel.
|
||||
- [x] No explicit scope toggle remains in the search panel.
|
||||
- [x] No recovery panel remains in empty-result states.
|
||||
- [x] `Did you mean` renders directly under the input when present.
|
||||
|
||||
### FE-ZL-003 - History cleanup and low-emphasis clear action
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-ZL-002
|
||||
Owners: Developer (FE)
|
||||
Task description:
|
||||
@@ -54,32 +54,34 @@ Task description:
|
||||
- Replace the current clear-history button with a discrete icon action.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Recent history excludes searches with zero results.
|
||||
- [ ] Clear-history affordance is icon-based and visually low emphasis.
|
||||
- [ ] Search history tests cover the new behavior.
|
||||
- [x] Recent history excludes searches with zero results.
|
||||
- [x] Clear-history affordance is icon-based and visually low emphasis.
|
||||
- [x] Search history tests cover the new behavior.
|
||||
|
||||
### FE-ZL-004 - Focused FE verification
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: FE-ZL-003
|
||||
Owners: Test Automation
|
||||
Task description:
|
||||
- Add or update Angular and Playwright tests for the consolidated UI model.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Unit tests cover removed controls and new placement rules.
|
||||
- [ ] Playwright covers the new top-bar, history, and `Did you mean` behavior.
|
||||
- [ ] Tests do not rely on deprecated `mode` or `scope` UI controls.
|
||||
- [x] Unit tests cover removed controls and new placement rules.
|
||||
- [x] Playwright covers the new top-bar, history, and `Did you mean` behavior.
|
||||
- [x] Tests do not rely on deprecated `mode` or `scope` UI controls.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-07 | Sprint created from the zero-learning search strategy. | Project Manager |
|
||||
| 2026-03-07 | Implemented the search-first shell: added the secondary AdvisoryAI launcher, removed explicit mode/scope/recovery controls, moved `Did you mean` under the input, migrated recent history to success-only storage, and updated focused Angular plus Playwright coverage. Commands: `npm test -- --include src/tests/global_search/global-search.component.spec.ts`; `npx playwright test tests/e2e/unified-search-contextual-suggestions.e2e.spec.ts tests/e2e/unified-search-self-serve-answer-panel.e2e.spec.ts tests/e2e/unified-search-experience-quality.e2e.spec.ts --config playwright.config.ts`. Results: `22/22` unit tests passed and `11/11` Playwright tests passed. | Developer / Test Automation |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision: the user should not be asked to choose a search mode before entering a query.
|
||||
- Decision: page scope becomes implicit UX, not an explicit control.
|
||||
- Risk: removing explicit controls may expose gaps in backend ranking that modes were previously masking.
|
||||
- Mitigation: phase 2 adds implicit-scope weighting and answer blending on the backend.
|
||||
- Verification note: focused Playwright runs still log an unrelated Angular `NG0602` console error from `PlatformContextUrlSyncService.initialize` plus several refused background requests. The targeted search flows remained green, but that runtime issue should be handled separately by the stabilization workstream.
|
||||
|
||||
## Next Checkpoints
|
||||
- 2026-03-08: Implement FE-ZL-001 through FE-ZL-003.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@ import type { EntityCard } from '../../app/core/api/unified-search.models';
|
||||
import { UnifiedSearchClient } from '../../app/core/api/unified-search.client';
|
||||
import { AmbientContextService } from '../../app/core/services/ambient-context.service';
|
||||
import { SearchChatContextService } from '../../app/core/services/search-chat-context.service';
|
||||
import { SearchExperienceModeService } from '../../app/core/services/search-experience-mode.service';
|
||||
import { I18nService } from '../../app/core/i18n';
|
||||
import { GlobalSearchComponent } from '../../app/layout/global-search/global-search.component';
|
||||
|
||||
@@ -18,7 +17,6 @@ describe('GlobalSearchComponent', () => {
|
||||
let routerEvents: Subject<unknown>;
|
||||
let router: { url: string; events: Subject<unknown>; navigateByUrl: jasmine.Spy; navigate: jasmine.Spy };
|
||||
let searchChatContext: jasmine.SpyObj<SearchChatContextService>;
|
||||
let searchExperienceMode: SearchExperienceModeService;
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
@@ -168,7 +166,6 @@ describe('GlobalSearchComponent', () => {
|
||||
|
||||
fixture = TestBed.createComponent(GlobalSearchComponent);
|
||||
component = fixture.componentInstance;
|
||||
searchExperienceMode = TestBed.inject(SearchExperienceModeService);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
@@ -302,12 +299,12 @@ describe('GlobalSearchComponent', () => {
|
||||
|
||||
it('navigates to assistant host with openChat intent from Ask AI card action', () => {
|
||||
const card = createCard('findings', '/triage/findings/fnd-1');
|
||||
searchExperienceMode.setMode('act');
|
||||
|
||||
component.onAskAiFromCard(card);
|
||||
|
||||
expect(searchChatContext.setSearchToChat).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
mode: 'act',
|
||||
query: 'findings sample',
|
||||
suggestedPrompt: jasmine.stringMatching(/why it matters/i),
|
||||
}));
|
||||
expect(router.navigate).toHaveBeenCalledWith(
|
||||
['/security/triage'],
|
||||
@@ -413,30 +410,30 @@ describe('GlobalSearchComponent', () => {
|
||||
expect(component.isFocused()).toBeTrue();
|
||||
});
|
||||
|
||||
it('keeps the search panel open when focus moves into experience controls', async () => {
|
||||
it('keeps the search panel open when focus moves to the chat launcher', async () => {
|
||||
component.onFocus();
|
||||
component.onQueryChange('critical findings');
|
||||
await waitForDebounce();
|
||||
fixture.detectChanges();
|
||||
|
||||
const explainButton = fixture.nativeElement.querySelectorAll('.search__segment')[1] as HTMLButtonElement | undefined;
|
||||
expect(explainButton).toBeDefined();
|
||||
const chatLauncher = fixture.nativeElement.querySelector('.search__chat-launcher') as HTMLButtonElement | null;
|
||||
expect(chatLauncher).not.toBeNull();
|
||||
|
||||
explainButton!.focus();
|
||||
chatLauncher!.focus();
|
||||
component.onBlur();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
expect(component.isFocused()).toBeTrue();
|
||||
});
|
||||
|
||||
it('renders rescue actions when a query returns no results', async () => {
|
||||
it('does not render recovery actions when a query returns no results', async () => {
|
||||
component.onFocus();
|
||||
component.onQueryChange('no results');
|
||||
await waitForDebounce();
|
||||
fixture.detectChanges();
|
||||
|
||||
const rescueCards = fixture.nativeElement.querySelectorAll('.search__rescue-card');
|
||||
expect(rescueCards.length).toBe(4);
|
||||
expect(rescueCards.length).toBe(0);
|
||||
});
|
||||
|
||||
it('renders a grounded answer panel before search results', async () => {
|
||||
@@ -498,40 +495,31 @@ describe('GlobalSearchComponent', () => {
|
||||
expect(answerQuestions).toContain('Should I focus on reachable, production, or unresolved findings?');
|
||||
});
|
||||
|
||||
it('retries the active query globally when scope rescue toggles off page filtering', async () => {
|
||||
it('does not hard-filter search requests to the current route scope', async () => {
|
||||
ambientContext.buildContextFilter.and.returnValue({ domains: ['findings'] } as any);
|
||||
|
||||
component.onFocus();
|
||||
component.onQueryChange('critical findings');
|
||||
await waitForDebounce();
|
||||
|
||||
const scopedCall = searchClient.search.calls.mostRecent();
|
||||
expect(scopedCall).toBeDefined();
|
||||
expect(scopedCall!.args[1]).toEqual({ domains: ['findings'] });
|
||||
|
||||
searchClient.search.calls.reset();
|
||||
component.runRescueAction('scope');
|
||||
await waitForDebounce();
|
||||
|
||||
const globalCall = searchClient.search.calls.mostRecent();
|
||||
expect(globalCall).toBeDefined();
|
||||
expect(globalCall!.args[1]).toBeUndefined();
|
||||
expect(ambientContext.recordAction).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
action: 'search_rescue_scope',
|
||||
}));
|
||||
const searchCall = searchClient.search.calls.mostRecent();
|
||||
expect(searchCall).toBeDefined();
|
||||
expect(searchCall!.args[1]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('opens AdvisoryAI reformulation with the current mode and query context', () => {
|
||||
searchExperienceMode.setMode('explain');
|
||||
it('opens AdvisoryAI from the search bar with page and query context', () => {
|
||||
component.onFocus();
|
||||
component.query.set('mismatch');
|
||||
|
||||
component.runRescueAction('reformulate');
|
||||
const chatLauncher = fixture.nativeElement.querySelector('.search__chat-launcher') as HTMLButtonElement | null;
|
||||
expect(chatLauncher).not.toBeNull();
|
||||
|
||||
chatLauncher!.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(searchChatContext.setSearchToChat).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
query: 'mismatch',
|
||||
mode: 'explain',
|
||||
suggestedPrompt: jasmine.stringMatching(/Reformulate the search query "mismatch"/),
|
||||
suggestedPrompt: jasmine.stringMatching(/searched for "mismatch"/i),
|
||||
}));
|
||||
expect(router.navigate).toHaveBeenCalledWith(
|
||||
['/security/triage'],
|
||||
@@ -544,24 +532,67 @@ describe('GlobalSearchComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the route filter when search scope is toggled to global', async () => {
|
||||
it('does not render explicit mode or scope controls', async () => {
|
||||
ambientContext.buildContextFilter.and.returnValue({ domains: ['findings'] } as any);
|
||||
|
||||
component.onFocus();
|
||||
component.onQueryChange('CVE-2024-21626');
|
||||
await waitForDebounce();
|
||||
const pageScopedCall = searchClient.search.calls.mostRecent();
|
||||
expect(pageScopedCall).toBeDefined();
|
||||
expect(pageScopedCall!.args[1]).toEqual({ domains: ['findings'] });
|
||||
|
||||
searchClient.search.calls.reset();
|
||||
component.toggleSearchScope();
|
||||
await waitForDebounce();
|
||||
expect(fixture.nativeElement.querySelector('.search__segment')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.search__scope-chip')).toBeNull();
|
||||
});
|
||||
|
||||
expect(component.searchScope()).toBe('global');
|
||||
const unscopedCall = searchClient.search.calls.mostRecent();
|
||||
expect(unscopedCall).toBeDefined();
|
||||
expect(unscopedCall!.args[1]).toBeUndefined();
|
||||
it('filters zero-result entries from server-backed recent history', () => {
|
||||
searchClient.getHistory.and.returnValue(of([
|
||||
{
|
||||
historyId: 'hist-1',
|
||||
query: 'critical findings',
|
||||
resultCount: 3,
|
||||
createdAt: '2026-03-07T10:00:00Z',
|
||||
},
|
||||
{
|
||||
historyId: 'hist-2',
|
||||
query: 'database connectivity',
|
||||
resultCount: 0,
|
||||
createdAt: '2026-03-07T10:01:00Z',
|
||||
},
|
||||
] as any));
|
||||
|
||||
component.onFocus();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.recentSearches()).toEqual(['critical findings']);
|
||||
});
|
||||
|
||||
it('ignores legacy mixed-result local history keys and persists the successful-only key', () => {
|
||||
localStorage.setItem('stella-recent-searches', JSON.stringify(['old zero result', 'old success']));
|
||||
searchClient.search.and.returnValue(of({
|
||||
query: 'critical findings',
|
||||
topK: 10,
|
||||
cards: [createCard('findings', '/triage/findings/fnd-legacy')],
|
||||
synthesis: null,
|
||||
diagnostics: {
|
||||
ftsMatches: 1,
|
||||
vectorMatches: 0,
|
||||
entityCardCount: 1,
|
||||
durationMs: 2,
|
||||
usedVector: false,
|
||||
mode: 'fts-only',
|
||||
},
|
||||
}));
|
||||
|
||||
component.onFocus();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.recentSearches()).toEqual([]);
|
||||
|
||||
component.onQueryChange('critical findings');
|
||||
|
||||
return waitForDebounce().then(() => {
|
||||
expect(JSON.parse(localStorage.getItem('stella-successful-searches-v2') ?? '[]')).toEqual(['critical findings']);
|
||||
expect(localStorage.getItem('stella-recent-searches')).toBe(JSON.stringify(['old zero result', 'old success']));
|
||||
});
|
||||
});
|
||||
|
||||
function createCard(domain: EntityCard['domain'], route: string): EntityCard {
|
||||
|
||||
@@ -31,23 +31,10 @@ const criticalFindingResponse = buildResponse(
|
||||
},
|
||||
);
|
||||
|
||||
const broadenedScopeResponse = buildResponse(
|
||||
'scope sensitive outage',
|
||||
[
|
||||
policyCard({
|
||||
ruleId: 'DENY-CRITICAL-PROD',
|
||||
title: 'DENY-CRITICAL-PROD',
|
||||
snippet: 'Production deny rule linked to the active incident.',
|
||||
}),
|
||||
],
|
||||
{
|
||||
summary: 'The broader search found a policy blocker outside the page scope.',
|
||||
template: 'policy_overview',
|
||||
confidence: 'high',
|
||||
sourceCount: 1,
|
||||
domainsCovered: ['policy'],
|
||||
},
|
||||
);
|
||||
const correctionResponse = {
|
||||
...emptyResponse('critcal findings'),
|
||||
suggestions: [{ text: 'critical findings', reason: 'Close match in the active corpus.' }],
|
||||
};
|
||||
|
||||
const policyBlockerResponse = buildResponse(
|
||||
'policy blockers for CVE-2024-21626',
|
||||
@@ -73,7 +60,8 @@ test.describe('Unified Search - Experience Quality UX', () => {
|
||||
await setupAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
test('keeps keyboard-selected mode when handing off from search to AdvisoryAI', async ({ page }) => {
|
||||
test('opens AdvisoryAI from the secondary search-bar launcher with page and query context', async ({ page }) => {
|
||||
const capturedTurnBodies: Array<Record<string, unknown>> = [];
|
||||
await mockSearchResponses(page, (query) => {
|
||||
if (query.includes('critical findings')) {
|
||||
return criticalFindingResponse;
|
||||
@@ -82,9 +70,10 @@ test.describe('Unified Search - Experience Quality UX', () => {
|
||||
return emptyResponse(query);
|
||||
});
|
||||
await mockChatConversation(page, {
|
||||
content: 'AdvisoryAI is ready to explain the finding and cite evidence.',
|
||||
content: 'AdvisoryAI is ready to expand the answer and explain the next step.',
|
||||
citations: [{ type: 'finding', path: 'CVE-2024-21626', verified: true }],
|
||||
groundingScore: 0.94,
|
||||
onTurnCreate: (body) => capturedTurnBodies.push(body),
|
||||
});
|
||||
|
||||
await page.goto('/security/triage');
|
||||
@@ -94,37 +83,25 @@ test.describe('Unified Search - Experience Quality UX', () => {
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page, 1);
|
||||
|
||||
await page.locator('app-global-search input[type="text"]').focus();
|
||||
await waitForResults(page);
|
||||
|
||||
const explainButton = page.locator('.search__experience-bar').getByRole('button', { name: 'Explain' });
|
||||
await explainButton.focus();
|
||||
await explainButton.press('Enter');
|
||||
await expect(explainButton).toHaveClass(/search__segment--active/);
|
||||
|
||||
await page.locator('app-global-search input[type="text"]').focus();
|
||||
await waitForResults(page);
|
||||
await page.locator('.entity-card__action--ask-ai').first().click();
|
||||
await page.locator('.search__chat-launcher').click();
|
||||
|
||||
await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('.chat-mode-btn--active')).toHaveText(/Explain/i);
|
||||
await expect(page.locator('app-global-search input[type="text"]')).toHaveValue('');
|
||||
await expect.poll(() => capturedTurnBodies.length).toBeGreaterThan(0);
|
||||
|
||||
const turnBody = capturedTurnBodies.at(-1) ?? {};
|
||||
expect(String(turnBody['content'] ?? '')).toMatch(/critical findings/i);
|
||||
});
|
||||
|
||||
test('broadens zero-result searches to all domains and reruns the same query', async ({ page }) => {
|
||||
test('renders did-you-mean directly below the search bar and removes teaching controls', async ({ page }) => {
|
||||
const capturedRequests: Array<Record<string, unknown>> = [];
|
||||
await page.route('**/search/query**', async (route) => {
|
||||
const request = route.request().postDataJSON() as Record<string, unknown>;
|
||||
capturedRequests.push(request);
|
||||
|
||||
const query = String(request['q'] ?? '').toLowerCase();
|
||||
const filters = request['filters'] as Record<string, unknown> | undefined;
|
||||
const hasPageScope = Array.isArray(filters?.['domains']) && filters!['domains'].length > 0;
|
||||
|
||||
const response = query.includes('scope sensitive outage')
|
||||
? hasPageScope
|
||||
? emptyResponse('scope sensitive outage')
|
||||
: broadenedScopeResponse
|
||||
: emptyResponse(query);
|
||||
const response = query.includes('critcal findings')
|
||||
? correctionResponse
|
||||
: criticalFindingResponse;
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
@@ -136,28 +113,63 @@ test.describe('Unified Search - Experience Quality UX', () => {
|
||||
await page.goto('/security/triage');
|
||||
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await typeInSearch(page, 'scope sensitive outage');
|
||||
await typeInSearch(page, 'critcal findings');
|
||||
await waitForResults(page);
|
||||
await expect(page.locator('.search__empty')).toContainText(/no results found/i);
|
||||
await expect(page.locator('.search__rescue-card')).toHaveCount(4);
|
||||
|
||||
await page.locator('[data-rescue-action="scope"]').click();
|
||||
await page.locator('app-global-search input[type="text"]').focus();
|
||||
await waitForResults(page);
|
||||
await expect(page.locator('[data-role="search-scope"]')).toContainText(/All domains/i);
|
||||
await expect(page.locator('app-global-search input[type="text"]')).toHaveValue('scope sensitive outage');
|
||||
await expect(page.locator('.search__cards')).toContainText(/DENY-CRITICAL-PROD/i);
|
||||
const searchBar = page.locator('.search__input-wrapper');
|
||||
const didYouMean = page.locator('.did-you-mean--inline');
|
||||
|
||||
expect(capturedRequests[0]?.['q']).toBe('scope sensitive outage');
|
||||
await expect(didYouMean).toBeVisible();
|
||||
await expect(page.locator('.search__segment')).toHaveCount(0);
|
||||
await expect(page.locator('.search__scope-chip')).toHaveCount(0);
|
||||
await expect(page.locator('.search__rescue-card')).toHaveCount(0);
|
||||
|
||||
const searchBarBox = await searchBar.boundingBox();
|
||||
const didYouMeanBox = await didYouMean.boundingBox();
|
||||
expect(searchBarBox).not.toBeNull();
|
||||
expect(didYouMeanBox).not.toBeNull();
|
||||
expect(didYouMeanBox!.y).toBeGreaterThan(searchBarBox!.y);
|
||||
expect(didYouMeanBox!.y - (searchBarBox!.y + searchBarBox!.height)).toBeLessThan(20);
|
||||
|
||||
const request = capturedRequests[0] ?? {};
|
||||
expect(request['filters']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('opens AdvisoryAI reformulation from the zero-result rescue flow', async ({ page }) => {
|
||||
await mockSearchResponses(page, (query) =>
|
||||
query.includes('mystery remediation') ? emptyResponse('mystery remediation') : emptyResponse(query));
|
||||
await mockChatConversation(page, {
|
||||
content: 'I can reformulate that query for better recall.',
|
||||
citations: [{ type: 'docs', path: 'modules/ui/search-chip-context-contract.md', verified: true }],
|
||||
groundingScore: 0.91,
|
||||
test('shows only successful history entries and clears them with the icon action', async ({ page }) => {
|
||||
let historyCleared = false;
|
||||
await mockSearchResponses(page, (query) => {
|
||||
if (query.includes('critical findings')) {
|
||||
return criticalFindingResponse;
|
||||
}
|
||||
|
||||
return emptyResponse(query);
|
||||
});
|
||||
await page.route('**/api/v1/advisory-ai/search/history', async (route) => {
|
||||
if (route.request().method() === 'DELETE') {
|
||||
historyCleared = true;
|
||||
return route.fulfill({ status: 204, body: '' });
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
entries: [
|
||||
{
|
||||
historyId: 'history-1',
|
||||
query: 'critical findings',
|
||||
resultCount: 2,
|
||||
createdAt: '2026-03-07T11:00:00Z',
|
||||
},
|
||||
{
|
||||
historyId: 'history-2',
|
||||
query: 'database connectivity',
|
||||
resultCount: 0,
|
||||
createdAt: '2026-03-07T11:01:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/security/triage');
|
||||
@@ -165,18 +177,19 @@ test.describe('Unified Search - Experience Quality UX', () => {
|
||||
|
||||
await page.locator('app-global-search input[type="text"]').focus();
|
||||
await waitForResults(page);
|
||||
await page.locator('.search__experience-bar').getByRole('button', { name: 'Explain' }).click();
|
||||
await typeInSearch(page, 'mystery remediation');
|
||||
await waitForResults(page);
|
||||
await expect(page.locator('.search__empty')).toContainText(/no results found/i);
|
||||
|
||||
await page.locator('[data-rescue-action="reformulate"]').click();
|
||||
await expect(page.locator('.search__group').filter({ hasText: 'Recent' })).toContainText('critical findings');
|
||||
await expect(page.locator('.search__group').filter({ hasText: 'Recent' })).not.toContainText('database connectivity');
|
||||
|
||||
await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('.chat-mode-btn--active')).toHaveText(/Explain/i);
|
||||
await expect(page.locator('.chat-message.user .message-body').first()).toContainText(
|
||||
/Reformulate the search query "mystery remediation"/i,
|
||||
);
|
||||
const clearButton = page.locator('.search__clear-history');
|
||||
await expect(clearButton).toBeVisible();
|
||||
await expect(clearButton.locator('svg')).toBeVisible();
|
||||
await expect(clearButton).toHaveText('');
|
||||
|
||||
await clearButton.click();
|
||||
|
||||
await expect.poll(() => historyCleared).toBe(true);
|
||||
await expect(page.locator('.search__group').filter({ hasText: 'Recent' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('returns structured next-step policy searches back into global search with metadata', async ({ page }) => {
|
||||
@@ -211,11 +224,6 @@ test.describe('Unified Search - Experience Quality UX', () => {
|
||||
await typeInSearch(page, 'critical findings');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page, 1);
|
||||
await page.locator('app-global-search input[type="text"]').focus();
|
||||
await waitForResults(page);
|
||||
await page.locator('.search__experience-bar').getByRole('button', { name: 'Act' }).click();
|
||||
await page.locator('app-global-search input[type="text"]').focus();
|
||||
await waitForResults(page);
|
||||
await page.locator('.entity-card__action--ask-ai').first().click();
|
||||
|
||||
await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 });
|
||||
@@ -226,10 +234,10 @@ test.describe('Unified Search - Experience Quality UX', () => {
|
||||
await expect(page.locator('.assistant-drawer')).toBeHidden({ timeout: 10_000 });
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page, 1);
|
||||
await expect(page.locator('app-global-search input[type="text"]')).toHaveValue(/policy blockers for CVE-2024-21626/i);
|
||||
await expect(page.locator('app-global-search input[type="text"]')).toHaveValue(/policy .*CVE-2024-21626/i);
|
||||
|
||||
const policyRequest = capturedRequests.find((request) =>
|
||||
String(request['q'] ?? '').toLowerCase().includes('policy blockers for cve-2024-21626'));
|
||||
/policy .*cve-2024-21626/i.test(String(request['q'] ?? '')));
|
||||
const ambient = policyRequest?.['ambient'] as Record<string, unknown> | undefined;
|
||||
const lastAction = ambient?.['lastAction'] as Record<string, unknown> | undefined;
|
||||
|
||||
@@ -260,6 +268,8 @@ async function mockChatConversation(
|
||||
content: string;
|
||||
citations: Array<{ type: string; path: string; verified: boolean }>;
|
||||
groundingScore: number;
|
||||
onConversationCreate?: (body: Record<string, unknown>) => void;
|
||||
onTurnCreate?: (body: Record<string, unknown>) => void;
|
||||
},
|
||||
): Promise<void> {
|
||||
await page.route('**/api/v1/advisory-ai/conversations', async (route) => {
|
||||
@@ -271,6 +281,9 @@ async function mockChatConversation(
|
||||
});
|
||||
}
|
||||
|
||||
const requestBody = (route.request().postDataJSON() as Record<string, unknown> | null) ?? {};
|
||||
response.onConversationCreate?.(requestBody);
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
@@ -291,6 +304,9 @@ async function mockChatConversation(
|
||||
return route.continue();
|
||||
}
|
||||
|
||||
const requestBody = (route.request().postDataJSON() as Record<string, unknown> | null) ?? {};
|
||||
response.onTurnCreate?.(requestBody);
|
||||
|
||||
const events = [
|
||||
'event: progress',
|
||||
'data: {"stage":"searching"}',
|
||||
|
||||
@@ -133,21 +133,20 @@ test.describe('Unified Search - Self-Serve Answer Panel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('opens AdvisoryAI from the answer panel with mode-aware context', async ({ page }) => {
|
||||
test('opens AdvisoryAI from the answer panel with grounded answer context', async ({ page }) => {
|
||||
const capturedTurnBodies: Array<Record<string, unknown>> = [];
|
||||
await mockSearchResponses(page, (query) =>
|
||||
query.includes('critical findings') ? groundedFindingResponse : emptyResponse(query));
|
||||
await mockChatConversation(page, {
|
||||
content: 'I can expand the grounded answer, explain the evidence, and recommend the safest next step.',
|
||||
citations: [{ type: 'finding', path: 'CVE-2024-21626', verified: true }],
|
||||
groundingScore: 0.95,
|
||||
onTurnCreate: (body) => capturedTurnBodies.push(body),
|
||||
});
|
||||
|
||||
await page.goto('/security/triage');
|
||||
await expect(page.locator('aside.sidebar')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.locator('app-global-search input[type="text"]').focus();
|
||||
await waitForResults(page);
|
||||
await page.locator('.search__experience-bar').getByRole('button', { name: 'Act' }).click();
|
||||
await typeInSearch(page, 'critical findings');
|
||||
await waitForResults(page);
|
||||
await waitForEntityCards(page, 1);
|
||||
@@ -155,9 +154,11 @@ test.describe('Unified Search - Self-Serve Answer Panel', () => {
|
||||
await page.locator('[data-answer-action="ask-ai"]').click();
|
||||
|
||||
await expect(page.locator('.assistant-drawer')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('.chat-mode-btn--active')).toHaveText(/Act/i);
|
||||
await expect(page.locator('.chat-message.user .message-body').first()).toContainText(/Expand the grounded answer/i);
|
||||
await expect(page.locator('.chat-message.user .message-body').first()).toContainText(/critical findings/i);
|
||||
await expect.poll(() => capturedTurnBodies.length).toBeGreaterThan(0);
|
||||
expect(String(capturedTurnBodies.at(-1)?.['content'] ?? '')).toMatch(/Expand the grounded answer/i);
|
||||
expect(String(capturedTurnBodies.at(-1)?.['content'] ?? '')).toMatch(/critical findings/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,6 +183,7 @@ async function mockChatConversation(
|
||||
content: string;
|
||||
citations: Array<{ type: string; path: string; verified: boolean }>;
|
||||
groundingScore: number;
|
||||
onTurnCreate?: (body: Record<string, unknown>) => void;
|
||||
},
|
||||
): Promise<void> {
|
||||
await page.route('**/api/v1/advisory-ai/conversations', async (route) => {
|
||||
@@ -213,6 +215,9 @@ async function mockChatConversation(
|
||||
return route.continue();
|
||||
}
|
||||
|
||||
const requestBody = (route.request().postDataJSON() as Record<string, unknown> | null) ?? {};
|
||||
response.onTurnCreate?.(requestBody);
|
||||
|
||||
const events = [
|
||||
'event: progress',
|
||||
'data: {"stage":"searching"}',
|
||||
|
||||
Reference in New Issue
Block a user