diff --git a/page-objects/ai-assistant.page.ts b/page-objects/ai-assistant.page.ts new file mode 100644 index 0000000..23170ca --- /dev/null +++ b/page-objects/ai-assistant.page.ts @@ -0,0 +1,59 @@ +import { Page, Locator } from '@playwright/test'; + +/** + * AI Assistant Page Object + * + * Encapsulates locators and interactions for the AI Assistant page at /ai/assistant. + * Handles both enabled state (chat UI) and disabled state (info banner). + */ +export class AiAssistantPage { + readonly page: Page; + readonly url = '/ai/assistant'; + + // ── Disabled state ──────────────────────────────────────────────────────── + readonly disabledBanner: Locator; + + // ── Enabled state — chat UI ─────────────────────────────────────────────── + readonly chatCard: Locator; + readonly messageInput: Locator; + readonly sendButton: Locator; + readonly clearButton: Locator; + readonly messages: Locator; + readonly loadingRow: Locator; + readonly errorRow: Locator; + readonly emptyState: Locator; + + constructor(page: Page) { + this.page = page; + + this.disabledBanner = page.locator('.ai-disabled-banner, .disabled-card').first(); + + this.chatCard = page.locator('mat-card').first(); + this.messageInput = page.locator('input[placeholder*="AI assistant"], input[placeholder*="anything"]').first(); + this.sendButton = page.locator('button').filter({ hasText: /^send$/i }).first(); + this.clearButton = page.locator('button[mat-icon-button]').filter({ hasText: /delete_sweep/i }).first(); + this.messages = page.locator('.message'); + this.loadingRow = page.locator('.loading-row'); + this.errorRow = page.locator('.error-row'); + this.emptyState = page.locator('.empty-state'); + } + + async goto() { + await this.page.goto(this.url); + await this.page.waitForLoadState('networkidle'); + } + + async isDisabled(): Promise { + return await this.disabledBanner.isVisible({ timeout: 3000 }).catch(() => false); + } + + async sendMessage(message: string) { + await this.messageInput.fill(message); + await this.page.keyboard.press('Enter'); + await this.page.waitForTimeout(500); + } + + async getMessageCount(): Promise { + return await this.messages.count(); + } +} diff --git a/page-objects/ai-hr-insight.page.ts b/page-objects/ai-hr-insight.page.ts new file mode 100644 index 0000000..ba65ff3 --- /dev/null +++ b/page-objects/ai-hr-insight.page.ts @@ -0,0 +1,64 @@ +import { Page, Locator } from '@playwright/test'; + +/** + * AI HR Insight Page Object + * + * Encapsulates locators and interactions for the HR Insight page at /ai/hr-insight. + * Handles both enabled state (chat UI with suggestion chips) and disabled state (info banner). + */ +export class AiHrInsightPage { + readonly page: Page; + readonly url = '/ai/hr-insight'; + + // ── Disabled state ──────────────────────────────────────────────────────── + readonly disabledBanner: Locator; + + // ── Enabled state — HR chat UI ──────────────────────────────────────────── + readonly chatCard: Locator; + readonly questionInput: Locator; + readonly askButton: Locator; + readonly clearButton: Locator; + readonly suggestionButtons: Locator; + readonly messages: Locator; + readonly loadingRow: Locator; + readonly errorRow: Locator; + + constructor(page: Page) { + this.page = page; + + this.disabledBanner = page.locator('.ai-disabled-banner, .disabled-card').first(); + + this.chatCard = page.locator('mat-card').first(); + this.questionInput = page.locator('input[placeholder*="workforce"], input[placeholder*="question"]').first(); + this.askButton = page.locator('button').filter({ hasText: /^ask$/i }).first(); + this.clearButton = page.locator('button[mat-icon-button]').filter({ hasText: /delete_sweep/i }).first(); + this.suggestionButtons = page.locator('.suggestion-list button, .suggestions button'); + this.messages = page.locator('.message'); + this.loadingRow = page.locator('.loading-row'); + this.errorRow = page.locator('.error-row'); + } + + async goto() { + await this.page.goto(this.url); + await this.page.waitForLoadState('networkidle'); + } + + async isDisabled(): Promise { + return await this.disabledBanner.isVisible({ timeout: 3000 }).catch(() => false); + } + + async getSuggestionCount(): Promise { + return await this.suggestionButtons.count(); + } + + async clickSuggestion(index: number) { + await this.suggestionButtons.nth(index).click(); + await this.page.waitForTimeout(500); + } + + async sendQuestion(question: string) { + await this.questionInput.fill(question); + await this.page.keyboard.press('Enter'); + await this.page.waitForTimeout(500); + } +} diff --git a/page-objects/ai-nl-search.page.ts b/page-objects/ai-nl-search.page.ts new file mode 100644 index 0000000..87b57eb --- /dev/null +++ b/page-objects/ai-nl-search.page.ts @@ -0,0 +1,70 @@ +import { Page, Locator } from '@playwright/test'; + +/** + * AI NL Search Page Object + * + * Encapsulates locators and interactions for the NL Search page at /ai/nl-search. + * Handles both enabled state (search UI with results table) and disabled state (info banner). + */ +export class AiNlSearchPage { + readonly page: Page; + readonly url = '/ai/nl-search'; + + // ── Disabled state ──────────────────────────────────────────────────────── + readonly disabledBanner: Locator; + + // ── Enabled state — search UI ───────────────────────────────────────────── + readonly searchInput: Locator; + readonly clearButton: Locator; + readonly parsedExpression: Locator; + readonly resultsTable: Locator; + readonly resultRows: Locator; + readonly resultCount: Locator; + readonly loadingRow: Locator; + readonly errorRow: Locator; + readonly emptyState: Locator; + + constructor(page: Page) { + this.page = page; + + this.disabledBanner = page.locator('.ai-disabled-banner, .disabled-card').first(); + + this.searchInput = page.locator('input[placeholder*="employee"], input[placeholder*="natural"]').first(); + this.clearButton = page.locator('button').filter({ hasText: /clear/i }).first(); + this.parsedExpression = page.locator('.parsed-expression'); + this.resultsTable = page.locator('table.results-table, mat-table').first(); + this.resultRows = page.locator('table.results-table tr:not(thead tr), mat-row'); + this.resultCount = page.locator('.result-count'); + this.loadingRow = page.locator('.loading-row'); + this.errorRow = page.locator('.error-row'); + this.emptyState = page.locator('.empty-state'); + } + + async goto() { + await this.page.goto(this.url); + await this.page.waitForLoadState('networkidle'); + } + + async isDisabled(): Promise { + return await this.disabledBanner.isVisible({ timeout: 3000 }).catch(() => false); + } + + async search(query: string) { + await this.searchInput.fill(query); + // Wait for debounce (600ms) + network + await this.page.waitForTimeout(1500); + } + + async clear() { + await this.clearButton.click(); + await this.page.waitForTimeout(500); + } + + async getResultCount(): Promise { + return await this.resultRows.count(); + } + + async hasParsedExpression(): Promise { + return await this.parsedExpression.isVisible({ timeout: 2000 }).catch(() => false); + } +} diff --git a/page-objects/ai-vector-search.page.ts b/page-objects/ai-vector-search.page.ts new file mode 100644 index 0000000..b6559b6 --- /dev/null +++ b/page-objects/ai-vector-search.page.ts @@ -0,0 +1,70 @@ +import { Page, Locator } from '@playwright/test'; + +/** + * AI Vector Search Page Object + * + * Encapsulates locators and interactions for the Vector Search page at /ai/vector-search. + * Handles both enabled state (search UI with scored results) and disabled state (info banner). + */ +export class AiVectorSearchPage { + readonly page: Page; + readonly url = '/ai/vector-search'; + + // ── Disabled state ──────────────────────────────────────────────────────── + readonly disabledBanner: Locator; + + // ── Enabled state — search UI ───────────────────────────────────────────── + readonly searchInput: Locator; + readonly clearButton: Locator; + readonly resultsTable: Locator; + readonly resultRows: Locator; + readonly scoreBadges: Locator; + readonly resultCount: Locator; + readonly loadingRow: Locator; + readonly errorRow: Locator; + readonly emptyState: Locator; + + constructor(page: Page) { + this.page = page; + + this.disabledBanner = page.locator('.ai-disabled-banner, .disabled-card').first(); + + this.searchInput = page.locator('input[placeholder*="position"], input[placeholder*="describe"]').first(); + this.clearButton = page.locator('button').filter({ hasText: /clear/i }).first(); + this.resultsTable = page.locator('table.results-table, mat-table').first(); + this.resultRows = page.locator('table.results-table tr:not(thead tr), mat-row'); + this.scoreBadges = page.locator('.score-badge'); + this.resultCount = page.locator('.result-count'); + this.loadingRow = page.locator('.loading-row'); + this.errorRow = page.locator('.error-row'); + this.emptyState = page.locator('.empty-state'); + } + + async goto() { + await this.page.goto(this.url); + await this.page.waitForLoadState('networkidle'); + } + + async isDisabled(): Promise { + return await this.disabledBanner.isVisible({ timeout: 3000 }).catch(() => false); + } + + async search(query: string) { + await this.searchInput.fill(query); + // Wait for debounce (600ms) + network + await this.page.waitForTimeout(1500); + } + + async clear() { + await this.clearButton.click(); + await this.page.waitForTimeout(500); + } + + async getResultCount(): Promise { + return await this.resultRows.count(); + } + + async getScoreBadgeCount(): Promise { + return await this.scoreBadges.count(); + } +} diff --git a/tests/ai/ai-assistant.spec.ts b/tests/ai/ai-assistant.spec.ts new file mode 100644 index 0000000..4b0d8c7 --- /dev/null +++ b/tests/ai/ai-assistant.spec.ts @@ -0,0 +1,150 @@ +import { test, expect } from '@playwright/test'; +import { loginAsRole } from '../../fixtures/auth.fixtures'; +import { AiAssistantPage } from '../../page-objects/ai-assistant.page'; + +/** + * AI Assistant Page Tests + * + * Tests for the AI Assistant page at /ai/assistant: + * - Page renders (disabled banner OR chat UI) + * - Disabled state shows info banner + * - Enabled state shows chat card, input, send button + * - Chat interaction (when AI is enabled) + * - Clear conversation + */ + +test.describe('AI Assistant Page', () => { + test.beforeEach(async ({ page }) => { + await loginAsRole(page, 'manager'); + }); + + test('should load the AI Assistant page without errors', async ({ page }) => { + const aiPage = new AiAssistantPage(page); + await aiPage.goto(); + + // Should not show a 404 or unhandled error + const hasError = await page.locator('text=/page not found|404|error occurred/i') + .isVisible({ timeout: 2000 }).catch(() => false); + expect(hasError).toBe(false); + + // Should show either the disabled banner OR the chat card — not a blank page + const hasBanner = await aiPage.isDisabled(); + const hasChatCard = await aiPage.chatCard.isVisible({ timeout: 3000 }).catch(() => false); + expect(hasBanner || hasChatCard).toBe(true); + }); + + test('should show disabled banner when AI is not enabled', async ({ page }) => { + const aiPage = new AiAssistantPage(page); + await aiPage.goto(); + + if (await aiPage.isDisabled()) { + // Banner should explain how to enable AI + const bannerText = await aiPage.disabledBanner.textContent(); + expect(bannerText).toMatch(/aiEnabled|AiEnabled|disabled/i); + } else { + test.skip(); // AI is enabled — disabled state test not applicable + } + }); + + test('should show chat UI when AI is enabled', async ({ page }) => { + const aiPage = new AiAssistantPage(page); + await aiPage.goto(); + + if (await aiPage.isDisabled()) { + test.skip(); // AI is disabled — chat UI test not applicable + } + + // Chat card should be visible + await expect(aiPage.chatCard).toBeVisible({ timeout: 5000 }); + + // Message input should be visible + const inputVisible = await aiPage.messageInput.isVisible({ timeout: 3000 }).catch(() => false); + if (inputVisible) { + await expect(aiPage.messageInput).toBeVisible(); + } else { + // Check for any text input in the chat area + const anyInput = page.locator('mat-card input[type="text"], mat-card textarea').first(); + await expect(anyInput).toBeVisible({ timeout: 3000 }); + } + }); + + test('should show empty state before any messages', async ({ page }) => { + const aiPage = new AiAssistantPage(page); + await aiPage.goto(); + + if (await aiPage.isDisabled()) { + test.skip(); + } + + // Empty state should be visible initially + const emptyVisible = await aiPage.emptyState.isVisible({ timeout: 3000 }).catch(() => false); + const messageCount = await aiPage.getMessageCount(); + + // Either empty state shown OR no messages yet + expect(emptyVisible || messageCount === 0).toBe(true); + }); + + test('should send a message and get a response (requires AI enabled)', async ({ page }) => { + test.setTimeout(60000); // Ollama inference can take up to 30s + + const aiPage = new AiAssistantPage(page); + await aiPage.goto(); + + if (await aiPage.isDisabled()) { + test.skip(); // Cannot test chat without AI enabled + } + + const inputVisible = await aiPage.messageInput.isVisible({ timeout: 3000 }).catch(() => false); + if (!inputVisible) { + test.skip(); // No input field found + } + + const initialCount = await aiPage.getMessageCount(); + + await aiPage.sendMessage('What is Angular?'); + + // Loading indicator should appear briefly + await page.waitForTimeout(500); + + // Wait for a response (up to 45s for Ollama) + await page.waitForFunction( + (initialCount) => document.querySelectorAll('.message').length > initialCount, + initialCount, + { timeout: 45000 } + ).catch(() => {}); // Don't fail if Ollama is slow + + // Check that at least the user message was added + const afterCount = await aiPage.getMessageCount(); + expect(afterCount).toBeGreaterThan(initialCount); + }); + + test('should clear conversation', async ({ page }) => { + const aiPage = new AiAssistantPage(page); + await aiPage.goto(); + + if (await aiPage.isDisabled()) { + test.skip(); + } + + // Send a message to create some history first + const inputVisible = await aiPage.messageInput.isVisible({ timeout: 3000 }).catch(() => false); + if (!inputVisible) { + test.skip(); + } + + await aiPage.sendMessage('Test message for clearing'); + await page.waitForTimeout(500); + + // Click clear button + const clearVisible = await aiPage.clearButton.isVisible({ timeout: 2000 }).catch(() => false); + if (clearVisible) { + await aiPage.clearButton.click(); + await page.waitForTimeout(500); + + // Empty state should be back + const emptyVisible = await aiPage.emptyState.isVisible({ timeout: 3000 }).catch(() => false); + const messageCount = await aiPage.getMessageCount(); + expect(emptyVisible || messageCount === 0).toBe(true); + } + }); +}); diff --git a/tests/ai/ai-hr-insight.spec.ts b/tests/ai/ai-hr-insight.spec.ts new file mode 100644 index 0000000..535b8b7 --- /dev/null +++ b/tests/ai/ai-hr-insight.spec.ts @@ -0,0 +1,128 @@ +import { test, expect } from '@playwright/test'; +import { loginAsRole } from '../../fixtures/auth.fixtures'; +import { AiHrInsightPage } from '../../page-objects/ai-hr-insight.page'; + +/** + * AI HR Insight Page Tests + * + * Tests for the HR Insight page at /ai/hr-insight: + * - Page renders (disabled banner OR chat UI) + * - Disabled state shows info banner + * - Enabled state shows suggestion buttons and chat input + * - Asking an HR question (when AI is enabled) + */ + +test.describe('AI HR Insight Page', () => { + test.beforeEach(async ({ page }) => { + await loginAsRole(page, 'manager'); + }); + + test('should load the HR Insight page without errors', async ({ page }) => { + const hrPage = new AiHrInsightPage(page); + await hrPage.goto(); + + const hasError = await page.locator('text=/page not found|404|error occurred/i') + .isVisible({ timeout: 2000 }).catch(() => false); + expect(hasError).toBe(false); + + const hasBanner = await hrPage.isDisabled(); + const hasChatCard = await hrPage.chatCard.isVisible({ timeout: 3000 }).catch(() => false); + expect(hasBanner || hasChatCard).toBe(true); + }); + + test('should show disabled banner when AI is not enabled', async ({ page }) => { + const hrPage = new AiHrInsightPage(page); + await hrPage.goto(); + + if (await hrPage.isDisabled()) { + const bannerText = await hrPage.disabledBanner.textContent(); + expect(bannerText).toMatch(/aiEnabled|AiEnabled|disabled/i); + } else { + test.skip(); + } + }); + + test('should show suggestion buttons when AI is enabled', async ({ page }) => { + const hrPage = new AiHrInsightPage(page); + await hrPage.goto(); + + if (await hrPage.isDisabled()) { + test.skip(); + } + + // HR Insight page should show suggestion chips/buttons + const suggestionCount = await hrPage.getSuggestionCount(); + expect(suggestionCount).toBeGreaterThan(0); + }); + + test('should show question input and ask button', async ({ page }) => { + const hrPage = new AiHrInsightPage(page); + await hrPage.goto(); + + if (await hrPage.isDisabled()) { + test.skip(); + } + + // Either the dedicated question input or a generic text input + const inputVisible = await hrPage.questionInput.isVisible({ timeout: 3000 }).catch(() => false); + if (!inputVisible) { + const anyInput = page.locator('mat-card input[type="text"], mat-card textarea').first(); + await expect(anyInput).toBeVisible({ timeout: 3000 }); + } else { + await expect(hrPage.questionInput).toBeVisible(); + } + }); + + test('should fill input when suggestion button is clicked', async ({ page }) => { + const hrPage = new AiHrInsightPage(page); + await hrPage.goto(); + + if (await hrPage.isDisabled()) { + test.skip(); + } + + const suggestionCount = await hrPage.getSuggestionCount(); + if (suggestionCount === 0) { + test.skip(); + } + + await hrPage.clickSuggestion(0); + + // Input should now be filled with the suggestion text + const inputVisible = await hrPage.questionInput.isVisible({ timeout: 2000 }).catch(() => false); + if (inputVisible) { + const inputValue = await hrPage.questionInput.inputValue(); + expect(inputValue.length).toBeGreaterThan(0); + } + }); + + test('should ask an HR question and get a response (requires AI enabled)', async ({ page }) => { + test.setTimeout(90000); // HR insight is slower — fetches DB data + Ollama inference + + const hrPage = new AiHrInsightPage(page); + await hrPage.goto(); + + if (await hrPage.isDisabled()) { + test.skip(); + } + + const inputVisible = await hrPage.questionInput.isVisible({ timeout: 3000 }).catch(() => false); + if (!inputVisible) { + test.skip(); + } + + const initialCount = await hrPage.messages.count(); + + await hrPage.sendQuestion('How many employees are there?'); + + // Wait for user message to appear + await page.waitForFunction( + (initialCount) => document.querySelectorAll('.message').length > initialCount, + initialCount, + { timeout: 60000 } + ).catch(() => {}); + + const afterCount = await hrPage.messages.count(); + expect(afterCount).toBeGreaterThan(initialCount); + }); +}); diff --git a/tests/ai/ai-navigation.spec.ts b/tests/ai/ai-navigation.spec.ts new file mode 100644 index 0000000..1aae6df --- /dev/null +++ b/tests/ai/ai-navigation.spec.ts @@ -0,0 +1,111 @@ +import { test, expect } from '@playwright/test'; +import { loginAsRole } from '../../fixtures/auth.fixtures'; + +/** + * AI Submenu Navigation Tests + * + * Tests for the AI section navigation: + * - AI submenu appears in sidebar + * - Each child route is reachable + * - /ai redirects to /ai/assistant + * - /ai-chat backward-compat redirect + */ + +test.describe('AI Submenu Navigation', () => { + test.beforeEach(async ({ page }) => { + await loginAsRole(page, 'manager'); + await page.goto('/dashboard'); + await page.waitForLoadState('networkidle'); + }); + + test('should navigate directly to /ai/assistant', async ({ page }) => { + await page.goto('/ai/assistant'); + await page.waitForLoadState('networkidle'); + + expect(page.url()).toContain('/ai/assistant'); + await expect(page.locator('page-header, app-page-header, .page-header').first()).toBeVisible({ timeout: 5000 }); + }); + + test('should navigate directly to /ai/hr-insight', async ({ page }) => { + await page.goto('/ai/hr-insight'); + await page.waitForLoadState('networkidle'); + + expect(page.url()).toContain('/ai/hr-insight'); + await expect(page.locator('page-header, app-page-header, .page-header').first()).toBeVisible({ timeout: 5000 }); + }); + + test('should navigate directly to /ai/nl-search', async ({ page }) => { + await page.goto('/ai/nl-search'); + await page.waitForLoadState('networkidle'); + + expect(page.url()).toContain('/ai/nl-search'); + await expect(page.locator('page-header, app-page-header, .page-header').first()).toBeVisible({ timeout: 5000 }); + }); + + test('should navigate directly to /ai/vector-search', async ({ page }) => { + await page.goto('/ai/vector-search'); + await page.waitForLoadState('networkidle'); + + expect(page.url()).toContain('/ai/vector-search'); + await expect(page.locator('page-header, app-page-header, .page-header').first()).toBeVisible({ timeout: 5000 }); + }); + + test('should redirect /ai to /ai/assistant', async ({ page }) => { + await page.goto('/ai'); + await page.waitForLoadState('networkidle'); + + expect(page.url()).toContain('/ai/assistant'); + }); + + test('should redirect /ai-chat to /ai/assistant (backward compat)', async ({ page }) => { + await page.goto('/ai-chat'); + await page.waitForLoadState('networkidle'); + + // Should redirect to assistant, not 404 + expect(page.url()).toContain('/ai/assistant'); + await expect(page.locator('page-header, app-page-header, .page-header').first()).toBeVisible({ timeout: 5000 }); + }); + + test('should show AI submenu in sidebar', async ({ page }) => { + // Look for AI entry in the sidebar navigation + const aiMenuItem = page.locator('mat-sidenav a, aside a, nav a, mat-nav-list a') + .filter({ hasText: /^ai$/i }) + .first(); + + const isVisible = await aiMenuItem.isVisible({ timeout: 3000 }).catch(() => false); + + if (isVisible) { + // AI submenu parent is visible in sidebar + expect(isVisible).toBe(true); + } else { + // Fallback: verify the routes are accessible even if sidebar item isn't found + await page.goto('/ai/assistant'); + await page.waitForLoadState('networkidle'); + expect(page.url()).toContain('/ai/assistant'); + } + }); + + test('should navigate between AI child pages', async ({ page }) => { + const routes = ['/ai/assistant', '/ai/hr-insight', '/ai/nl-search', '/ai/vector-search']; + + for (const route of routes) { + await page.goto(route); + await page.waitForLoadState('networkidle'); + expect(page.url()).toContain(route); + + // Each page should render without error + const hasError = await page.locator('text=/error|not found|404/i').isVisible({ timeout: 1000 }).catch(() => false); + expect(hasError).toBe(false); + } + }); + + test('should require authentication to access AI pages', async ({ page }) => { + // Navigate without login (logout first by going to a page that checks auth) + await page.goto('/ai/assistant'); + await page.waitForLoadState('networkidle'); + + // When logged in (from beforeEach), should be accessible + expect(page.url()).not.toContain('/auth/login'); + expect(page.url()).not.toContain('/403'); + }); +}); diff --git a/tests/ai/ai-nl-search.spec.ts b/tests/ai/ai-nl-search.spec.ts new file mode 100644 index 0000000..838c434 --- /dev/null +++ b/tests/ai/ai-nl-search.spec.ts @@ -0,0 +1,154 @@ +import { test, expect } from '@playwright/test'; +import { loginAsRole } from '../../fixtures/auth.fixtures'; +import { AiNlSearchPage } from '../../page-objects/ai-nl-search.page'; + +/** + * AI NL Search Page Tests + * + * Tests for the Natural Language Search page at /ai/nl-search: + * - Page renders (disabled banner OR search UI) + * - Disabled state shows info banner + * - Enabled state shows search input + * - Search input and clear button work + * - Parsed expression appears after a query (when AI enabled) + */ + +test.describe('AI NL Search Page', () => { + test.beforeEach(async ({ page }) => { + await loginAsRole(page, 'manager'); + }); + + test('should load the NL Search page without errors', async ({ page }) => { + const nlPage = new AiNlSearchPage(page); + await nlPage.goto(); + + const hasError = await page.locator('text=/page not found|404|error occurred/i') + .isVisible({ timeout: 2000 }).catch(() => false); + expect(hasError).toBe(false); + + const hasBanner = await nlPage.isDisabled(); + const hasSearchCard = await page.locator('mat-card').first().isVisible({ timeout: 3000 }).catch(() => false); + expect(hasBanner || hasSearchCard).toBe(true); + }); + + test('should show disabled banner when AI is not enabled', async ({ page }) => { + const nlPage = new AiNlSearchPage(page); + await nlPage.goto(); + + if (await nlPage.isDisabled()) { + const bannerText = await nlPage.disabledBanner.textContent(); + expect(bannerText).toMatch(/aiEnabled|AiEnabled|disabled/i); + } else { + test.skip(); + } + }); + + test('should show search input when AI is enabled', async ({ page }) => { + const nlPage = new AiNlSearchPage(page); + await nlPage.goto(); + + if (await nlPage.isDisabled()) { + test.skip(); + } + + // Should have a text input for NL queries + const inputVisible = await nlPage.searchInput.isVisible({ timeout: 3000 }).catch(() => false); + if (!inputVisible) { + // Check for any search-type input + const anyInput = page.locator('mat-card input[type="text"]').first(); + await expect(anyInput).toBeVisible({ timeout: 3000 }); + } else { + await expect(nlPage.searchInput).toBeVisible(); + } + }); + + test('should show initial empty state prompt', async ({ page }) => { + const nlPage = new AiNlSearchPage(page); + await nlPage.goto(); + + if (await nlPage.isDisabled()) { + test.skip(); + } + + // Initial empty state should show a prompt to start searching + const emptyVisible = await nlPage.emptyState.isVisible({ timeout: 3000 }).catch(() => false); + expect(emptyVisible).toBe(true); + }); + + test('should type into the search input', async ({ page }) => { + const nlPage = new AiNlSearchPage(page); + await nlPage.goto(); + + if (await nlPage.isDisabled()) { + test.skip(); + } + + const inputVisible = await nlPage.searchInput.isVisible({ timeout: 3000 }).catch(() => false); + if (!inputVisible) { + test.skip(); + } + + await nlPage.searchInput.fill('software engineers'); + const value = await nlPage.searchInput.inputValue(); + expect(value).toBe('software engineers'); + }); + + test('should show parsed expression and results after search (requires AI enabled)', async ({ page }) => { + test.setTimeout(30000); + + const nlPage = new AiNlSearchPage(page); + await nlPage.goto(); + + if (await nlPage.isDisabled()) { + test.skip(); + } + + const inputVisible = await nlPage.searchInput.isVisible({ timeout: 3000 }).catch(() => false); + if (!inputVisible) { + test.skip(); + } + + // Type a query and wait for debounce + AI response + await nlPage.search('employees in IT department'); + + // Loading should appear and then resolve + await page.waitForTimeout(2000); + + // Either results table or parsed expression or no-match state should be visible + const hasParsed = await nlPage.hasParsedExpression(); + const hasResults = await nlPage.resultsTable.isVisible({ timeout: 3000 }).catch(() => false); + const hasEmpty = await page.locator('.empty-state').isVisible({ timeout: 1000 }).catch(() => false); + const hasError = await nlPage.errorRow.isVisible({ timeout: 1000 }).catch(() => false); + + expect(hasParsed || hasResults || hasEmpty || hasError).toBe(true); + }); + + test('should clear the search input', async ({ page }) => { + const nlPage = new AiNlSearchPage(page); + await nlPage.goto(); + + if (await nlPage.isDisabled()) { + test.skip(); + } + + const inputVisible = await nlPage.searchInput.isVisible({ timeout: 3000 }).catch(() => false); + if (!inputVisible) { + test.skip(); + } + + await nlPage.searchInput.fill('test query'); + await page.waitForTimeout(300); + + // Find and click the Clear button + const clearButton = page.locator('button').filter({ hasText: /clear/i }).first(); + const clearVisible = await clearButton.isVisible({ timeout: 2000 }).catch(() => false); + + if (clearVisible) { + await clearButton.click(); + await page.waitForTimeout(300); + + const value = await nlPage.searchInput.inputValue(); + expect(value).toBe(''); + } + }); +}); diff --git a/tests/ai/ai-vector-search.spec.ts b/tests/ai/ai-vector-search.spec.ts new file mode 100644 index 0000000..0280184 --- /dev/null +++ b/tests/ai/ai-vector-search.spec.ts @@ -0,0 +1,178 @@ +import { test, expect } from '@playwright/test'; +import { loginAsRole } from '../../fixtures/auth.fixtures'; +import { AiVectorSearchPage } from '../../page-objects/ai-vector-search.page'; + +/** + * AI Vector Search Page Tests + * + * Tests for the Vector Search page at /ai/vector-search: + * - Page renders (disabled banner OR search UI) + * - Disabled state shows info banner + * - Enabled state shows search input + * - Score badges appear in results + * - Results have correct columns (score, title, dept, salary range) + */ + +test.describe('AI Vector Search Page', () => { + test.beforeEach(async ({ page }) => { + await loginAsRole(page, 'manager'); + }); + + test('should load the Vector Search page without errors', async ({ page }) => { + const vsPage = new AiVectorSearchPage(page); + await vsPage.goto(); + + const hasError = await page.locator('text=/page not found|404|error occurred/i') + .isVisible({ timeout: 2000 }).catch(() => false); + expect(hasError).toBe(false); + + const hasBanner = await vsPage.isDisabled(); + const hasCard = await page.locator('mat-card').first().isVisible({ timeout: 3000 }).catch(() => false); + expect(hasBanner || hasCard).toBe(true); + }); + + test('should show disabled banner when AI is not enabled', async ({ page }) => { + const vsPage = new AiVectorSearchPage(page); + await vsPage.goto(); + + if (await vsPage.isDisabled()) { + const bannerText = await vsPage.disabledBanner.textContent(); + expect(bannerText).toMatch(/aiEnabled|VectorSearchEnabled|disabled/i); + } else { + test.skip(); + } + }); + + test('should show search input when AI is enabled', async ({ page }) => { + const vsPage = new AiVectorSearchPage(page); + await vsPage.goto(); + + if (await vsPage.isDisabled()) { + test.skip(); + } + + const inputVisible = await vsPage.searchInput.isVisible({ timeout: 3000 }).catch(() => false); + if (!inputVisible) { + const anyInput = page.locator('mat-card input[type="text"]').first(); + await expect(anyInput).toBeVisible({ timeout: 3000 }); + } else { + await expect(vsPage.searchInput).toBeVisible(); + } + }); + + test('should show initial empty state prompt', async ({ page }) => { + const vsPage = new AiVectorSearchPage(page); + await vsPage.goto(); + + if (await vsPage.isDisabled()) { + test.skip(); + } + + const emptyVisible = await vsPage.emptyState.isVisible({ timeout: 3000 }).catch(() => false); + expect(emptyVisible).toBe(true); + }); + + test('should type into the search input', async ({ page }) => { + const vsPage = new AiVectorSearchPage(page); + await vsPage.goto(); + + if (await vsPage.isDisabled()) { + test.skip(); + } + + const inputVisible = await vsPage.searchInput.isVisible({ timeout: 3000 }).catch(() => false); + if (!inputVisible) { + test.skip(); + } + + await vsPage.searchInput.fill('senior software engineer'); + const value = await vsPage.searchInput.inputValue(); + expect(value).toBe('senior software engineer'); + }); + + test('should show scored results after search (requires VectorSearch enabled)', async ({ page }) => { + test.setTimeout(30000); + + const vsPage = new AiVectorSearchPage(page); + await vsPage.goto(); + + if (await vsPage.isDisabled()) { + test.skip(); + } + + const inputVisible = await vsPage.searchInput.isVisible({ timeout: 3000 }).catch(() => false); + if (!inputVisible) { + test.skip(); + } + + await vsPage.search('senior engineer with cloud experience'); + + await page.waitForTimeout(2000); + + // After the search, one of these should be visible + const hasResults = await vsPage.resultsTable.isVisible({ timeout: 5000 }).catch(() => false); + const hasEmpty = await page.locator('.empty-state').isVisible({ timeout: 1000 }).catch(() => false); + const hasError = await vsPage.errorRow.isVisible({ timeout: 1000 }).catch(() => false); + + expect(hasResults || hasEmpty || hasError).toBe(true); + }); + + test('should show score badges in results (requires VectorSearch enabled)', async ({ page }) => { + test.setTimeout(30000); + + const vsPage = new AiVectorSearchPage(page); + await vsPage.goto(); + + if (await vsPage.isDisabled()) { + test.skip(); + } + + const inputVisible = await vsPage.searchInput.isVisible({ timeout: 3000 }).catch(() => false); + if (!inputVisible) { + test.skip(); + } + + await vsPage.search('software engineer'); + await page.waitForTimeout(3000); + + const hasResults = await vsPage.resultsTable.isVisible({ timeout: 3000 }).catch(() => false); + if (!hasResults) { + test.skip(); // No results — vector search not returning data + } + + // Results should have score badges + const badgeCount = await vsPage.getScoreBadgeCount(); + expect(badgeCount).toBeGreaterThan(0); + + // Score badge format should be a percentage + const firstBadge = await vsPage.scoreBadges.first().textContent(); + expect(firstBadge).toMatch(/\d+%/); + }); + + test('should clear the search input', async ({ page }) => { + const vsPage = new AiVectorSearchPage(page); + await vsPage.goto(); + + if (await vsPage.isDisabled()) { + test.skip(); + } + + const inputVisible = await vsPage.searchInput.isVisible({ timeout: 3000 }).catch(() => false); + if (!inputVisible) { + test.skip(); + } + + await vsPage.searchInput.fill('cloud engineer role'); + await page.waitForTimeout(300); + + const clearButton = page.locator('button').filter({ hasText: /clear/i }).first(); + const clearVisible = await clearButton.isVisible({ timeout: 2000 }).catch(() => false); + + if (clearVisible) { + await clearButton.click(); + await page.waitForTimeout(300); + const value = await vsPage.searchInput.inputValue(); + expect(value).toBe(''); + } + }); +}); diff --git a/tests/screenshots/blog-screenshots.spec.ts b/tests/screenshots/blog-screenshots.spec.ts index 91277c6..ba17fa2 100644 --- a/tests/screenshots/blog-screenshots.spec.ts +++ b/tests/screenshots/blog-screenshots.spec.ts @@ -571,166 +571,213 @@ test.describe('Series 3 — Angular Material', () => { // --------------------------------------------------------------------------- test.describe('Series 6 — AI Features', () => { - test('dashboard — AI insights card', async ({ page }) => { + test('AI submenu — sidebar navigation visible', async ({ page }) => { await loginAsRole(page, 'manager'); await page.goto('/dashboard'); - await settle(page, 2000); - - const aiCard = page.locator('.ai-insights-card, mat-card:has(mat-icon:has-text("smart_toy"))').first(); - - if (await aiCard.count() > 0) { - await page.waitForSelector('.ai-insight-text, .ai-insights-card p', { timeout: 30000 }) - .catch(() => {}); - await page.waitForTimeout(1000); - - await shot(page, 'series-6-ai-app-features', 'dashboard-ai-insights-card.png', { - description: - 'Dashboard with AI Insights mat-card at the top — LLM-generated plain-English executive summary of live workforce metrics from Ollama.', - narration: - 'With AI enabled, the dashboard now opens with an executive summary generated by Ollama. The card appears above the metric cards and automatically refreshes each time the dashboard loads with the latest workforce data.', - articles: ['6.4'], - tags: ['dashboard', 'ai-insights', 'ollama', 'executive-summary', 'mat-card'], - useFor: 'Hero image for Article 6.4; shows AI card in context above the metric cards.', - }); + await settle(page, 1500); - const box = await aiCard.boundingBox(); - if (box) { - await shot(page, 'series-6-ai-app-features', 'dashboard-ai-insights-card-closeup.png', { - description: - 'Close-up of the AI Insights mat-card — smart_toy icon, title, and generated executive summary text.', - narration: - 'The AI insights card uses the smart toy Material icon, a card title, and the generated summary text. The summary is typically three to four sentences and references the actual numbers from the database.', - articles: ['6.4'], - tags: ['dashboard', 'ai-insights', 'mat-card', 'closeup', 'smart-toy-icon'], - useFor: 'Inline image for the Article 6.4 step-by-step walkthrough showing the finished card.', - }, { clip: { x: box.x, y: box.y, width: box.width, height: Math.min(box.height, 300) } }); - } - } else { - await shot(page, 'series-6-ai-app-features', 'dashboard-ai-disabled-state.png', { - description: - 'Dashboard without AI insights card — state when aiEnabled is false. Original dashboard is completely unaffected.', - narration: - 'When the AI feature flag is disabled, the dashboard looks identical to the pre-Series 6 version. No empty card, no spinner, no trace of the AI feature — the original tutorial experience is fully preserved.', - articles: ['6.4'], - tags: ['dashboard', 'ai-disabled', 'feature-flag', 'graceful-degradation'], - useFor: 'Show the before state (AI disabled) before the after state (AI enabled) in Article 6.4.', - }); - } + await shot(page, 'series-6-ai-app-features', 'ai-submenu-sidebar.png', { + description: + 'Dashboard with the AI submenu expanded in the sidebar — shows four child items: AI Assistant, HR Insight, NL Search, Vector Search.', + narration: + 'The AI section lives in its own collapsible group in the sidebar. Clicking the smart toy icon expands four child pages, each with its own dedicated route.', + articles: ['6.3'], + tags: ['ai-submenu', 'sidebar', 'menu-json', 'ng-matero', 'navigation'], + useFor: 'Hero image for Article 6.3 showing the submenu structure.', + }); }); - test('AI chat widget — full page', async ({ page }) => { + test('AI Assistant — full page', async ({ page }) => { await loginAsRole(page, 'manager'); - await page.goto('/ai-chat'); + await page.goto('/ai/assistant'); await settle(page, 1500); - await shot(page, 'series-6-ai-app-features', 'ai-chat-page-full.png', { + await shot(page, 'series-6-ai-app-features', 'ai-assistant-page-full.png', { description: - 'Full AI Assistant page at /ai-chat — two-tab chat UI when aiEnabled is true, or an info banner when false.', + 'Full AI Assistant page at /ai/assistant — chat card with message input when aiEnabled is true, or an info banner when false.', narration: - 'The AI Assistant page is accessible from the sidebar using the robot toy icon. When AI is enabled it shows two tabs: General Chat and H R Insights. When disabled, an informational banner explains what to enable.', + 'The AI Assistant page is one of four pages in the AI submenu. When AI is enabled it shows a chat card with a message input and send button. When disabled, an info banner explains what to enable.', articles: ['6.3'], - tags: ['ai-chat', 'mat-tab-group', 'feature-flag', 'angular-material'], - useFor: 'Hero image for Article 6.3 showing the complete chat page layout.', + tags: ['ai-assistant', 'chat-ui', 'feature-flag', 'angular-material'], + useFor: 'Hero image for Article 6.3 showing the AI Assistant page.', }, { fullPage: true }); }); - test('AI chat — Tab 1 general chat with reply', async ({ page }) => { + test('AI Assistant — disabled banner', async ({ page }) => { await loginAsRole(page, 'manager'); - await page.goto('/ai-chat'); + await page.goto('/ai/assistant'); await settle(page, 1500); - const tabGroup = page.locator('mat-tab-group').first(); - if (await tabGroup.count() === 0) { - await shot(page, 'series-6-ai-app-features', 'ai-chat-disabled-banner.png', { - description: - 'AI Assistant page showing info banner when aiEnabled is false — explains Ollama must be running and AiEnabled set to true.', - narration: - 'When the AI flag is off, a friendly info banner explains what to enable. This is the experience for developers following the original tutorial without Ollama installed.', - articles: ['6.3'], - tags: ['ai-chat', 'disabled-state', 'feature-flag', 'info-banner'], - useFor: 'Show the graceful disabled state in Article 6.3 before enabling the feature.', - }); + const banner = page.locator('.ai-disabled-banner, .disabled-card').first(); + if (await banner.count() === 0) { + test.skip(); // AI is enabled — disabled state screenshot not applicable return; } - await shot(page, 'series-6-ai-app-features', 'ai-chat-tab1-general-empty.png', { + await shot(page, 'series-6-ai-app-features', 'ai-assistant-disabled-banner.png', { description: - 'AI Assistant Tab 1 (General Chat) — empty state before any messages are sent.', + 'AI Assistant page showing info banner when aiEnabled is false — explains environment.ts and appsettings.json settings.', narration: - 'Tab 1 is General Chat — a free-form conversation with Ollama running locally. Type any question and press Enter to send. The conversation history accumulates in the session.', + 'When the AI flag is off, a friendly info banner explains what to enable. All four AI pages show this same banner.', articles: ['6.3'], - tags: ['ai-chat', 'general-chat', 'tab1', 'empty-state'], - useFor: 'Show the initial empty chat state at the start of Article 6.3.', + tags: ['ai-assistant', 'disabled-state', 'feature-flag', 'info-banner'], + useFor: 'Show the graceful disabled state in Article 6.3.', }); + }); - const input = page.locator('mat-tab-body textarea, mat-tab-body input[type="text"]').first(); + test('AI Assistant — chat with reply', async ({ page }) => { + test.setTimeout(60000); + await loginAsRole(page, 'manager'); + await page.goto('/ai/assistant'); + await settle(page, 1500); + + const banner = page.locator('.ai-disabled-banner').first(); + if (await banner.count() > 0) return; // AI disabled — skip + + await shot(page, 'series-6-ai-app-features', 'ai-assistant-empty-state.png', { + description: + 'AI Assistant page — empty state before any messages, showing the "Start a conversation" prompt.', + narration: + 'The initial state shows an empty chat card with a prompt to start a conversation. The message input sits at the bottom with a Send button.', + articles: ['6.3'], + tags: ['ai-assistant', 'empty-state', 'chat-ui'], + useFor: 'Show the initial state at the start of the Article 6.3 demo.', + }); + + const input = page.locator('mat-card input[type="text"]').first(); if (await input.count() > 0) { await input.fill('What is OAuth 2.0 and how does it differ from OpenID Connect?'); await page.keyboard.press('Enter'); - await page.waitForSelector('[class*="assistant"], .message-assistant', { timeout: 30000 }) - .catch(() => {}); - await page.waitForTimeout(2000); + await page.waitForSelector('.message.assistant-message', { timeout: 30000 }).catch(() => {}); + await page.waitForTimeout(1500); - await shot(page, 'series-6-ai-app-features', 'ai-chat-tab1-with-reply.png', { + await shot(page, 'series-6-ai-app-features', 'ai-assistant-with-reply.png', { description: - 'AI Assistant Tab 1 with a user question about OAuth 2.0 vs OIDC and Ollama\'s reply — conversation history with user/assistant message bubbles.', + 'AI Assistant page showing a user question and Ollama reply — user message bubble on the right, assistant on the left.', narration: - 'Ollama responds with a detailed explanation of OAuth 2.0 versus OpenID Connect. The user message appears on the right, the assistant reply on the left — a familiar chat bubble layout using Angular Material components.', + 'The general chat response appears in a chat bubble below the user message. The conversation history accumulates for the session.', articles: ['6.3'], - tags: ['ai-chat', 'general-chat', 'tab1', 'ollama-reply', 'conversation'], - useFor: 'Show the live general chat in action in Article 6.3.', + tags: ['ai-assistant', 'chat-reply', 'ollama', 'conversation'], + useFor: 'Show the live chat in action in Article 6.3.', }); } }); - test('AI chat — Tab 2 HR insights with answer', async ({ page }) => { - test.setTimeout(90000); // Ollama inference can take 30-60s; allow extra buffer + test('AI HR Insight — suggestion buttons and answer', async ({ page }) => { + test.setTimeout(90000); await loginAsRole(page, 'manager'); - await page.goto('/ai-chat'); + await page.goto('/ai/hr-insight'); await settle(page, 1500); - const tabGroup = page.locator('mat-tab-group').first(); - if (await tabGroup.count() === 0) return; + const banner = page.locator('.ai-disabled-banner').first(); + if (await banner.count() > 0) return; - const hrTab = page.locator('[role="tab"]').filter({ hasText: /hr insight/i }).first(); - if (await hrTab.count() === 0) return; - - await hrTab.click(); - await page.waitForTimeout(800); - - await shot(page, 'series-6-ai-app-features', 'ai-chat-tab2-hr-insights-empty.png', { + await shot(page, 'series-6-ai-app-features', 'ai-hr-insight-empty.png', { description: - 'AI Assistant Tab 2 (HR Insights) — empty state showing four pre-filled suggestion buttons and text input.', + 'HR Insight page at /ai/hr-insight — empty state showing four suggestion buttons and the question input.', narration: - 'Tab 2 is H R Insights — questions answered using live workforce data from the database. Four suggestion buttons pre-fill common H R questions. You can also type your own.', + 'The H R Insight page shows suggestion buttons for common workforce questions. Clicking one pre-fills the input field.', articles: ['6.3'], - tags: ['ai-chat', 'hr-insights', 'tab2', 'suggestion-buttons', 'empty-state'], - useFor: 'Show the HR Insights tab layout and suggestion buttons in Article 6.3.', + tags: ['ai-hr-insight', 'suggestion-buttons', 'empty-state'], + useFor: 'Show the HR Insight page layout in Article 6.3.', }); - const suggestionBtn = page.locator('mat-tab-body button[mat-stroked-button], mat-tab-body button[mat-flat-button]').first(); + const suggestionBtn = page.locator('.suggestion-list button').first(); if (await suggestionBtn.count() > 0) { await suggestionBtn.click(); - } else { - const input = page.locator('mat-tab-body textarea, mat-tab-body input').nth(1); - if (await input.count() > 0) { - await input.fill('Which department has the most employees?'); - await page.keyboard.press('Enter'); - } + await page.waitForTimeout(500); } + const input = page.locator('mat-card input[type="text"]').first(); + if (await input.count() > 0 && (await input.inputValue()).length === 0) { + await input.fill('Which department has the most employees?'); + } + await page.keyboard.press('Enter'); + await page.waitForTimeout(30000).catch(() => {}); await page.waitForTimeout(1000); - await shot(page, 'series-6-ai-app-features', 'ai-chat-tab2-hr-insights-with-answer.png', { + await shot(page, 'series-6-ai-app-features', 'ai-hr-insight-with-answer.png', { description: - 'HR Insights Tab 2 showing a data-grounded Ollama answer — references actual department headcounts from the live database. Execution time shown below reply.', + 'HR Insight page showing a data-grounded Ollama answer — references live department headcounts. Execution time shown below reply.', narration: - 'The H R Insights answer references real numbers from the database — no hallucination. You can see the execution time below the reply, which includes both the database query time and the Ollama inference time. This is Retrieval Augmented Generation without a vector store.', + 'The H R Insight answer references real numbers from the database. The execution time shown below the reply includes both the database query and Ollama inference time.', articles: ['6.2', '6.3'], - tags: ['ai-chat', 'hr-insights', 'tab2', 'rag', 'grounded-answer', 'execution-time', 'ollama'], - useFor: 'Key proof-of-concept image for Articles 6.2 and 6.3 — shows AI grounded in real data.', + tags: ['ai-hr-insight', 'rag', 'grounded-answer', 'execution-time', 'ollama'], + useFor: 'Key proof-of-concept image for Articles 6.2 and 6.3.', }); }); + + test('AI NL Search — search and results', async ({ page }) => { + test.setTimeout(30000); + await loginAsRole(page, 'manager'); + await page.goto('/ai/nl-search'); + await settle(page, 1500); + + await shot(page, 'series-6-ai-app-features', 'ai-nl-search-empty.png', { + description: + 'Natural Language Search page at /ai/nl-search — empty state with search input and prompt to type a query.', + narration: + 'The NL Search page shows a single text input. Type a plain-English description of the employees you are looking for.', + articles: ['6.4'], + tags: ['ai-nl-search', 'empty-state', 'natural-language'], + useFor: 'Show the initial NL Search state in Article 6.4.', + }); + + const banner = page.locator('.ai-disabled-banner').first(); + if (await banner.count() > 0) return; + + const input = page.locator('mat-card input[type="text"]').first(); + if (await input.count() > 0) { + await input.fill('software engineers in IT'); + await page.waitForTimeout(2500); // debounce + AI response + + await shot(page, 'series-6-ai-app-features', 'ai-nl-search-with-results.png', { + description: + 'NL Search results — parsed filter expression shown above a Material table of matching employees.', + narration: + 'After parsing the query, the page shows the structured filter the LLM extracted and then displays matching employees from the live API in a data table.', + articles: ['6.4'], + tags: ['ai-nl-search', 'parsed-expression', 'results-table', 'natural-language'], + useFor: 'Show the complete NL Search flow in Article 6.4.', + }); + } + }); + + test('AI Vector Search — search and scored results', async ({ page }) => { + test.setTimeout(30000); + await loginAsRole(page, 'manager'); + await page.goto('/ai/vector-search'); + await settle(page, 1500); + + await shot(page, 'series-6-ai-app-features', 'ai-vector-search-empty.png', { + description: + 'Vector Search page at /ai/vector-search — empty state with search input and prompt to describe a position.', + narration: + 'The Vector Search page uses semantic similarity to find positions. Describe what you are looking for in plain English.', + articles: ['6.5'], + tags: ['ai-vector-search', 'empty-state', 'semantic-search'], + useFor: 'Show the initial Vector Search state in Article 6.5.', + }); + + const banner = page.locator('.ai-disabled-banner').first(); + if (await banner.count() > 0) return; + + const input = page.locator('mat-card input[type="text"]').first(); + if (await input.count() > 0) { + await input.fill('senior software engineer with cloud experience'); + await page.waitForTimeout(2500); // debounce + vector search response + + await shot(page, 'series-6-ai-app-features', 'ai-vector-search-with-results.png', { + description: + 'Vector Search results — positions ranked by semantic match score shown as green percentage badges.', + narration: + 'Results are sorted by match score. The green percentage badge shows how semantically close each position is to the query — positions with different titles but similar meanings appear.', + articles: ['6.5'], + tags: ['ai-vector-search', 'score-badge', 'semantic-similarity', 'results-table'], + useFor: 'Show the scored vector search results in Article 6.5.', + }); + } + }); });