From 3589531b58a72244373e2c3936711760e599af53 Mon Sep 17 00:00:00 2001 From: Fuji Nguyen Date: Mon, 20 Apr 2026 21:42:36 -0400 Subject: [PATCH 01/10] Add screenshots project and blog-screenshots spec for automated blog image capture --- playwright.config.ts | 16 + tests/screenshots/blog-screenshots.spec.ts | 455 +++++++++++++++++++++ 2 files changed, 471 insertions(+) create mode 100644 tests/screenshots/blog-screenshots.spec.ts diff --git a/playwright.config.ts b/playwright.config.ts index eaea23f..2a76355 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -146,6 +146,22 @@ export default defineConfig({ }, }, + // Blog Screenshots - captures key UI states for blog posts and documentation + { + name: 'screenshots', + testMatch: /tests\/screenshots\/.*\.spec\.ts/, + use: { + ...devices['Desktop Chrome'], + viewport: VIEWPORTS.laptop, + video: 'off', + screenshot: 'off', // manual screenshots only — no auto-capture + launchOptions: { + slowMo: 150, // slow down interactions so UI fully settles before capture + }, + }, + // No setup dependency — auth is handled inline per screenshot group + }, + /* Mobile viewports (optional - uncomment to enable) */ // { // name: 'Mobile Chrome', diff --git a/tests/screenshots/blog-screenshots.spec.ts b/tests/screenshots/blog-screenshots.spec.ts new file mode 100644 index 0000000..7a7a1a4 --- /dev/null +++ b/tests/screenshots/blog-screenshots.spec.ts @@ -0,0 +1,455 @@ +/** + * Blog Screenshots + * + * Captures key UI states from the TalentManagement app for use in blog posts + * and documentation. Run this suite whenever the UI changes or before publishing + * a new article. + * + * Usage: + * npx playwright test --project=screenshots + * + * Output: + * screenshots-output/ + * ├── series-0-architecture/ (anonymous state, sidebar, navigation) + * ├── series-1-authentication/ (login, dashboard, user menu, profile) + * ├── series-2-dotnet-api/ (Swagger UI) + * ├── series-3-angular-material/ (employee list/form, department list, dashboard charts) + * └── series-6-ai-app-features/ (AI chat widget, HR insights, dashboard AI card) + * + * Prerequisites: All three services must be running: + * - Angular: http://localhost:4200 + * - .NET API: https://localhost:44378 + * - IdentityServer: https://localhost:44310 + * + * For AI screenshots: set AiEnabled: true in API appsettings.json + * and aiEnabled: true in Angular environment.ts + */ + +import { test, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { loginAs, loginAsRole, logout } from '../../fixtures/auth.fixtures'; +import { APP_URLS } from '../../config/test-config'; + +// --------------------------------------------------------------------------- +// Helper +// --------------------------------------------------------------------------- + +/** Output root — siblings with the tests/ folder */ +const OUTPUT_ROOT = path.join(__dirname, '..', '..', 'screenshots-output'); + +/** + * Take a screenshot and save it to screenshots-output/{series}/{filename}. + * Creates intermediate directories automatically. + */ +async function shot( + page: Page, + series: string, + filename: string, + options: { fullPage?: boolean; clip?: { x: number; y: number; width: number; height: number } } = {} +): Promise { + const dir = path.join(OUTPUT_ROOT, series); + fs.mkdirSync(dir, { recursive: true }); + await page.screenshot({ + path: path.join(dir, filename), + fullPage: options.fullPage ?? false, + clip: options.clip, + }); +} + +/** Wait for the page to fully settle (network + extra render time). */ +async function settle(page: Page, ms = 1500): Promise { + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(ms); +} + +// --------------------------------------------------------------------------- +// Series 0 — Architecture / Anonymous state +// --------------------------------------------------------------------------- + +test.describe('Series 0 — Architecture', () => { + test('anonymous home page', async ({ page }) => { + await page.goto('/'); + await settle(page); + + // Full app in guest/anonymous state + await shot(page, 'series-0-architecture', 'anonymous-home.png', { fullPage: true }); + + // Sidebar navigation only + const sidebar = page.locator('mat-sidenav, .matero-sidenav, app-sidebar, nav').first(); + const box = await sidebar.boundingBox(); + if (box) { + await shot(page, 'series-0-architecture', 'sidebar-navigation.png', { + clip: { x: box.x, y: box.y, width: box.width, height: box.height }, + }); + } + }); + + test('full stack architecture — swagger confirms API is running', async ({ page }) => { + await page.goto(`${APP_URLS.api.replace('/api/v1', '')}/swagger`, { + waitUntil: 'networkidle', + timeout: 15000, + }); + await settle(page, 2000); + await shot(page, 'series-0-architecture', 'swagger-ui-overview.png', { fullPage: false }); + }); +}); + +// --------------------------------------------------------------------------- +// Series 1 — Authentication +// --------------------------------------------------------------------------- + +test.describe('Series 1 — Authentication', () => { + test('IdentityServer login form', async ({ page }) => { + await page.goto('/'); + await settle(page); + + // Open user menu and click login to reach IdentityServer + const userIcon = page.locator( + 'button[aria-label="User menu"], button mat-icon:has-text("account_circle"), header button:has(mat-icon)' + ).last(); + await userIcon.click(); + await page.waitForTimeout(500); + + const loginOption = page.locator( + 'button:has-text("Login"), a:has-text("Login"), [role="menuitem"]:has-text("Login")' + ).first(); + await loginOption.click(); + + // Wait for IdentityServer login page + await page.waitForSelector('input[name="Username"]', { timeout: 15000 }); + await settle(page, 1000); + await shot(page, 'series-1-authentication', 'identityserver-login-form.png'); + }); + + test('user menu — anonymous state', async ({ page }) => { + await page.goto('/'); + await settle(page); + + const userIcon = page.locator( + 'button[aria-label="User menu"], button mat-icon:has-text("account_circle"), header button:has(mat-icon)' + ).last(); + await userIcon.click(); + await page.waitForTimeout(800); + await shot(page, 'series-1-authentication', 'user-menu-anonymous.png'); + }); + + test('dashboard after login — manager role', async ({ page }) => { + await loginAsRole(page, 'manager'); + await settle(page, 2000); + await shot(page, 'series-1-authentication', 'dashboard-authenticated.png', { fullPage: false }); + }); + + test('user menu — authenticated state', async ({ page }) => { + await loginAsRole(page, 'manager'); + await settle(page); + + const userIcon = page.locator( + 'button[aria-label="User menu"], button mat-icon:has-text("account_circle"), header button:has(mat-icon)' + ).last(); + await userIcon.click(); + await page.waitForTimeout(800); + await shot(page, 'series-1-authentication', 'user-menu-authenticated.png'); + }); + + test('role-based sidebar — HRAdmin sees all menu items', async ({ page }) => { + await loginAsRole(page, 'hradmin'); + await settle(page); + const sidebar = page.locator('mat-sidenav, .matero-sidenav, app-sidebar, nav').first(); + const box = await sidebar.boundingBox(); + if (box) { + await shot(page, 'series-1-authentication', 'sidebar-hradmin-full-menu.png', { + clip: { x: box.x, y: box.y, width: box.width, height: box.height }, + }); + } + }); + + test('role-based sidebar — manager sees limited menu', async ({ page }) => { + await loginAsRole(page, 'manager'); + await settle(page); + const sidebar = page.locator('mat-sidenav, .matero-sidenav, app-sidebar, nav').first(); + const box = await sidebar.boundingBox(); + if (box) { + await shot(page, 'series-1-authentication', 'sidebar-manager-limited-menu.png', { + clip: { x: box.x, y: box.y, width: box.width, height: box.height }, + }); + } + }); + + test('IdentityServer logout screen', async ({ page }) => { + await loginAsRole(page, 'manager'); + await settle(page); + + // Trigger logout + const userIcon = page.locator( + 'button[aria-label="User menu"], button mat-icon:has-text("account_circle"), header button:has(mat-icon)' + ).last(); + await userIcon.click(); + await page.waitForTimeout(500); + + const logoutOption = page.locator( + 'button:has-text("Logout"), a:has-text("Logout"), [role="menuitem"]:has-text("Logout")' + ).first(); + await logoutOption.click(); + + // Wait for IdentityServer logout page + await page.waitForTimeout(3000); + await settle(page, 1000); + await shot(page, 'series-1-authentication', 'identityserver-logout-screen.png'); + }); +}); + +// --------------------------------------------------------------------------- +// Series 2 — .NET API (Swagger) +// --------------------------------------------------------------------------- + +test.describe('Series 2 — .NET API', () => { + const swaggerBase = APP_URLS.api.replace('/api/v1', '') + '/swagger'; + + test('swagger UI — full overview', async ({ page }) => { + await page.goto(swaggerBase, { waitUntil: 'networkidle', timeout: 15000 }); + await settle(page, 2000); + await shot(page, 'series-2-dotnet-api', 'swagger-full-overview.png', { fullPage: false }); + }); + + test('swagger UI — employees endpoints expanded', async ({ page }) => { + await page.goto(swaggerBase, { waitUntil: 'networkidle', timeout: 15000 }); + await settle(page, 2000); + + // Expand the Employees section + const employeesSection = page.locator('.opblock-tag-section, .opblock-tag').filter({ hasText: /employee/i }).first(); + if (await employeesSection.count() > 0) { + await employeesSection.click(); + await page.waitForTimeout(1000); + } + await shot(page, 'series-2-dotnet-api', 'swagger-employees-endpoints.png', { fullPage: false }); + }); + + test('swagger UI — AI endpoints expanded', async ({ page }) => { + await page.goto(swaggerBase, { waitUntil: 'networkidle', timeout: 15000 }); + await settle(page, 2000); + + // Expand the AI section + const aiSection = page.locator('.opblock-tag-section, .opblock-tag').filter({ hasText: /^ai$/i }).first(); + if (await aiSection.count() > 0) { + await aiSection.click(); + await page.waitForTimeout(1000); + await shot(page, 'series-2-dotnet-api', 'swagger-ai-endpoints.png', { fullPage: false }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Series 3 — Angular Material UI +// --------------------------------------------------------------------------- + +test.describe('Series 3 — Angular Material', () => { + test('dashboard — metrics cards and charts', async ({ page }) => { + await loginAsRole(page, 'manager'); + await page.goto('/dashboard'); + await settle(page, 3000); // extra time for charts to render + await shot(page, 'series-3-angular-material', 'dashboard-metrics-charts.png', { fullPage: true }); + }); + + test('employee list — data table with pagination', async ({ page }) => { + await loginAsRole(page, 'manager'); + await page.goto('/employees'); + await settle(page, 2000); + await shot(page, 'series-3-angular-material', 'employee-list-table.png', { fullPage: false }); + }); + + test('employee create form — dialog open', async ({ page }) => { + await loginAsRole(page, 'manager'); + await page.goto('/employees'); + await settle(page, 1500); + + // Open create dialog + const createBtn = page.locator('button').filter({ hasText: /create|add/i }).first(); + if (await createBtn.count() > 0) { + await createBtn.click(); + await page.waitForSelector('mat-dialog-container, .mat-dialog-container, form', { timeout: 5000 }); + await page.waitForTimeout(800); + await shot(page, 'series-3-angular-material', 'employee-create-form.png'); + } + }); + + test('department list', async ({ page }) => { + await loginAsRole(page, 'manager'); + await page.goto('/departments'); + await settle(page, 2000); + await shot(page, 'series-3-angular-material', 'department-list-table.png', { fullPage: false }); + }); + + test('position list — HRAdmin only', async ({ page }) => { + await loginAsRole(page, 'hradmin'); + await page.goto('/positions'); + await settle(page, 2000); + await shot(page, 'series-3-angular-material', 'position-list-table.png', { fullPage: false }); + }); + + test('salary ranges — HRAdmin only', async ({ page }) => { + await loginAsRole(page, 'hradmin'); + await page.goto('/salary-ranges'); + await settle(page, 2000); + await shot(page, 'series-3-angular-material', 'salary-ranges-table.png', { fullPage: false }); + }); +}); + +// --------------------------------------------------------------------------- +// Series 6 — AI Features +// --------------------------------------------------------------------------- + +test.describe('Series 6 — AI Features', () => { + test.beforeEach(async ({ page }) => { + // Skip entire group gracefully if AI is disabled in the environment + // (We check by navigating to /ai-chat and seeing if content loads) + }); + + test('dashboard — AI insights card loaded', async ({ page }) => { + await loginAsRole(page, 'manager'); + await page.goto('/dashboard'); + await settle(page, 2000); + + // Check if AI insights card is present (only when aiEnabled: true) + const aiCard = page.locator('.ai-insights-card, mat-card:has(mat-icon:has-text("smart_toy"))').first(); + const hasAiCard = await aiCard.count() > 0; + + if (hasAiCard) { + // Wait for AI insight text to appear (Ollama can take 5-15s) + await page.waitForSelector('.ai-insight-text, .ai-insights-card p', { timeout: 30000 }) + .catch(() => { /* AI may be disabled or loading — screenshot either state */ }); + await page.waitForTimeout(1000); + await shot(page, 'series-6-ai-app-features', 'dashboard-ai-insights-card.png', { fullPage: false }); + + // Crop just the AI card for a close-up + const box = await aiCard.boundingBox(); + if (box) { + await shot(page, 'series-6-ai-app-features', 'dashboard-ai-insights-card-closeup.png', { + clip: { x: box.x, y: box.y, width: box.width, height: Math.min(box.height, 300) }, + }); + } + } else { + // Screenshot dashboard anyway — documents the disabled state + await shot(page, 'series-6-ai-app-features', 'dashboard-ai-disabled-state.png', { fullPage: false }); + } + }); + + test('AI chat widget — disabled banner (aiEnabled: false)', async ({ page }) => { + await loginAsRole(page, 'manager'); + await page.goto('/ai-chat'); + await settle(page, 1500); + + // Capture whatever state we're in — could be the info banner or the live chat + await shot(page, 'series-6-ai-app-features', 'ai-chat-page-full.png', { fullPage: true }); + }); + + test('AI chat — Tab 1 general chat with a reply', async ({ page }) => { + await loginAsRole(page, 'manager'); + await page.goto('/ai-chat'); + await settle(page, 1500); + + // Check if tabs exist (only when aiEnabled: true) + const tabGroup = page.locator('mat-tab-group').first(); + const hasTabs = await tabGroup.count() > 0; + + if (!hasTabs) { + // AI disabled — screenshot the info banner + await shot(page, 'series-6-ai-app-features', 'ai-chat-disabled-banner.png', { fullPage: false }); + return; + } + + // Tab 1 is General Chat — it should be active by default + await shot(page, 'series-6-ai-app-features', 'ai-chat-tab1-general-empty.png', { fullPage: false }); + + // Type a question and send + const input = page.locator('mat-tab-body:first-child textarea, mat-tab-body:first-child 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'); + + // Wait for the AI reply (Ollama can take up to 15s) + await page.waitForSelector( + 'mat-tab-body:first-child .assistant, mat-tab-body:first-child [class*="assistant"]', + { timeout: 30000 } + ).catch(() => { /* capture whatever state loaded */ }); + await page.waitForTimeout(2000); + await shot(page, 'series-6-ai-app-features', 'ai-chat-tab1-with-reply.png', { fullPage: false }); + } + }); + + test('AI chat — Tab 2 HR insights with a data-grounded answer', async ({ page }) => { + await loginAsRole(page, 'manager'); + await page.goto('/ai-chat'); + await settle(page, 1500); + + const tabGroup = page.locator('mat-tab-group').first(); + if (await tabGroup.count() === 0) { + return; // AI disabled — skip + } + + // Switch to Tab 2 (HR Insights) + const hrTab = page.locator('[role="tab"]').filter({ hasText: /hr insight/i }).first(); + if (await hrTab.count() > 0) { + await hrTab.click(); + await page.waitForTimeout(800); + await shot(page, 'series-6-ai-app-features', 'ai-chat-tab2-hr-insights-empty.png', { fullPage: false }); + + // Click a suggestion button (pre-filled question) + const suggestionBtn = page.locator('mat-tab-body:nth-child(2) button[mat-stroked-button], mat-tab-body:nth-child(2) button[mat-flat-button]').first(); + if (await suggestionBtn.count() > 0) { + await suggestionBtn.click(); + } else { + // Fallback: type a question manually + const input = page.locator('mat-tab-body:nth-child(2) textarea, mat-tab-body:nth-child(2) input').first(); + if (await input.count() > 0) { + await input.fill('Which department has the most employees?'); + await page.keyboard.press('Enter'); + } + } + + // Wait for data-grounded reply + await page.waitForTimeout(30000).catch(() => {}); + await shot(page, 'series-6-ai-app-features', 'ai-chat-tab2-hr-insights-with-answer.png', { fullPage: false }); + } + }); + + test('AI chat — suggestion buttons visible', async ({ page }) => { + await loginAsRole(page, 'manager'); + await page.goto('/ai-chat'); + await settle(page, 1500); + + const hrTab = page.locator('[role="tab"]').filter({ hasText: /hr insight/i }).first(); + if (await hrTab.count() > 0) { + await hrTab.click(); + await page.waitForTimeout(800); + + // Capture the suggestion buttons row + const buttons = page.locator('mat-tab-body:nth-child(2) button').first(); + const box = await buttons.boundingBox(); + if (box) { + await shot(page, 'series-6-ai-app-features', 'ai-chat-suggestion-buttons.png', { fullPage: false }); + } + } + }); + + test('swagger — AI chat endpoint try-it-out', async ({ page }) => { + const swaggerBase = APP_URLS.api.replace('/api/v1', '') + '/swagger'; + await page.goto(swaggerBase, { waitUntil: 'networkidle', timeout: 15000 }); + await settle(page, 2000); + + // Expand AI section + const aiSection = page.locator('.opblock-tag-section, .opblock-tag').filter({ hasText: /^ai$/i }).first(); + if (await aiSection.count() > 0) { + await aiSection.click(); + await page.waitForTimeout(1000); + + // Expand the chat POST endpoint + const chatEndpoint = page.locator('.opblock-post').filter({ hasText: /\/ai\/chat/i }).first(); + if (await chatEndpoint.count() > 0) { + await chatEndpoint.click(); + await page.waitForTimeout(800); + await shot(page, 'series-6-ai-app-features', 'swagger-ai-chat-endpoint.png', { fullPage: false }); + } + } + }); +}); From 22ef524a8922b047ea76b6616aa08cbc19e70684 Mon Sep 17 00:00:00 2001 From: Fuji Nguyen Date: Mon, 20 Apr 2026 21:49:08 -0400 Subject: [PATCH 02/10] Add screenshot catalog with metadata for AI-assisted blog writing --- .gitignore | 5 + tests/screenshots/blog-screenshots.spec.ts | 487 ++++++++++++++------- 2 files changed, 336 insertions(+), 156 deletions(-) diff --git a/.gitignore b/.gitignore index 335bd46..998af49 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,8 @@ node_modules/ /blob-report/ /playwright/.cache/ /playwright/.auth/ + +# Blog screenshots — generated PNG files are not committed (too large, regenerate by running the screenshots project) +/screenshots-output/ + +# screenshot-catalog.json IS committed so AI tools can reference it across sessions without re-running tests diff --git a/tests/screenshots/blog-screenshots.spec.ts b/tests/screenshots/blog-screenshots.spec.ts index 7a7a1a4..11428b2 100644 --- a/tests/screenshots/blog-screenshots.spec.ts +++ b/tests/screenshots/blog-screenshots.spec.ts @@ -2,62 +2,112 @@ * Blog Screenshots * * Captures key UI states from the TalentManagement app for use in blog posts - * and documentation. Run this suite whenever the UI changes or before publishing - * a new article. + * and documentation. Each screenshot is registered in screenshot-catalog.json + * so AI tools can reference images by description when writing blog articles. * * Usage: * npx playwright test --project=screenshots * * Output: - * screenshots-output/ - * ├── series-0-architecture/ (anonymous state, sidebar, navigation) - * ├── series-1-authentication/ (login, dashboard, user menu, profile) - * ├── series-2-dotnet-api/ (Swagger UI) - * ├── series-3-angular-material/ (employee list/form, department list, dashboard charts) - * └── series-6-ai-app-features/ (AI chat widget, HR insights, dashboard AI card) + * screenshots-output/ ← PNG files organised by series + * screenshot-catalog.json ← Machine-readable index (regenerated each run) * * Prerequisites: All three services must be running: * - Angular: http://localhost:4200 * - .NET API: https://localhost:44378 * - IdentityServer: https://localhost:44310 * - * For AI screenshots: set AiEnabled: true in API appsettings.json - * and aiEnabled: true in Angular environment.ts + * For Series 6 AI screenshots: set AiEnabled: true in API appsettings.json + * and aiEnabled: true in Angular environment.ts */ import { test, Page } from '@playwright/test'; import * as fs from 'fs'; import * as path from 'path'; -import { loginAs, loginAsRole, logout } from '../../fixtures/auth.fixtures'; +import { loginAsRole } from '../../fixtures/auth.fixtures'; import { APP_URLS } from '../../config/test-config'; // --------------------------------------------------------------------------- -// Helper +// Catalog types +// --------------------------------------------------------------------------- + +interface ScreenshotMeta { + /** One or two sentence description of what the screenshot shows. Used by AI when selecting images for blog posts. */ + description: string; + /** Blog article numbers this screenshot is relevant to (e.g. ["1.1", "1.2"]) */ + articles: string[]; + /** Keywords for filtering (e.g. ["dashboard", "authentication", "manager"]) */ + tags: string[]; + /** How an AI writer should use this image in a blog (caption hint, placement suggestion) */ + useFor: string; +} + +interface CatalogEntry extends ScreenshotMeta { + path: string; // relative path from repo root: screenshots-output/series-x/filename.png + series: string; // e.g. "series-1-authentication" + filename: string; // e.g. "dashboard-authenticated.png" + capturedAt: string; // ISO timestamp +} + +interface Catalog { + generated: string; + screenshots: CatalogEntry[]; +} + +// --------------------------------------------------------------------------- +// Catalog writer (accumulates entries across tests in a single run) // --------------------------------------------------------------------------- -/** Output root — siblings with the tests/ folder */ const OUTPUT_ROOT = path.join(__dirname, '..', '..', 'screenshots-output'); +const CATALOG_PATH = path.join(__dirname, '..', '..', 'screenshot-catalog.json'); + +// Load existing catalog (if present) so we can merge/overwrite individual entries +let catalog: Catalog = { generated: new Date().toISOString(), screenshots: [] }; + +function saveCatalog(): void { + fs.writeFileSync(CATALOG_PATH, JSON.stringify(catalog, null, 2), 'utf-8'); +} + +// --------------------------------------------------------------------------- +// shot() — take screenshot + register catalog entry +// --------------------------------------------------------------------------- -/** - * Take a screenshot and save it to screenshots-output/{series}/{filename}. - * Creates intermediate directories automatically. - */ async function shot( page: Page, series: string, filename: string, - options: { fullPage?: boolean; clip?: { x: number; y: number; width: number; height: number } } = {} + meta: ScreenshotMeta, + options: { + fullPage?: boolean; + clip?: { x: number; y: number; width: number; height: number }; + } = {} ): Promise { const dir = path.join(OUTPUT_ROOT, series); fs.mkdirSync(dir, { recursive: true }); + + const filePath = path.join(dir, filename); await page.screenshot({ - path: path.join(dir, filename), + path: filePath, fullPage: options.fullPage ?? false, clip: options.clip, }); + + // Upsert catalog entry (replace if same series+filename already exists) + const relativePath = `screenshots-output/${series}/${filename}`; + catalog.screenshots = catalog.screenshots.filter( + e => !(e.series === series && e.filename === filename) + ); + catalog.screenshots.push({ + path: relativePath, + series, + filename, + capturedAt: new Date().toISOString(), + ...meta, + }); + saveCatalog(); } -/** Wait for the page to fully settle (network + extra render time). */ +/** Wait for network + extra render time. */ async function settle(page: Page, ms = 1500): Promise { await page.waitForLoadState('networkidle'); await page.waitForTimeout(ms); @@ -72,26 +122,41 @@ test.describe('Series 0 — Architecture', () => { await page.goto('/'); await settle(page); - // Full app in guest/anonymous state - await shot(page, 'series-0-architecture', 'anonymous-home.png', { fullPage: true }); + await shot(page, 'series-0-architecture', 'anonymous-home.png', { + description: + 'Full TalentManagement app in anonymous/Guest state — shows the sidebar with limited menu items, the header with the user icon, and an empty dashboard placeholder.', + articles: ['0.1', '0.2', '1.1'], + tags: ['anonymous', 'guest', 'home', 'sidebar', 'app-shell'], + useFor: 'Hero image for architecture overview articles; illustrates the app before login.', + }); - // Sidebar navigation only const sidebar = page.locator('mat-sidenav, .matero-sidenav, app-sidebar, nav').first(); const box = await sidebar.boundingBox(); if (box) { await shot(page, 'series-0-architecture', 'sidebar-navigation.png', { - clip: { x: box.x, y: box.y, width: box.width, height: box.height }, - }); + description: + 'Left sidebar showing the navigation menu items available to an anonymous (Guest) user — limited to public routes only.', + articles: ['0.1', '1.4'], + tags: ['sidebar', 'navigation', 'anonymous', 'menu'], + useFor: 'Illustrate the sidebar structure before discussing role-based menu visibility.', + }, { clip: { x: box.x, y: box.y, width: box.width, height: box.height } }); } }); - test('full stack architecture — swagger confirms API is running', async ({ page }) => { + test('swagger confirms API is running', async ({ page }) => { await page.goto(`${APP_URLS.api.replace('/api/v1', '')}/swagger`, { waitUntil: 'networkidle', timeout: 15000, }); await settle(page, 2000); - await shot(page, 'series-0-architecture', 'swagger-ui-overview.png', { fullPage: false }); + + await shot(page, 'series-0-architecture', 'swagger-ui-overview.png', { + description: + 'NSwag/Swagger UI for the TalentManagement .NET 10 Web API, showing all versioned controller groups (Employees, Departments, Positions, AI, etc.) collapsed.', + articles: ['0.1', '2.1', '2.4', '6.1'], + tags: ['swagger', 'api', 'dotnet', 'nswag', 'overview'], + useFor: 'Show the API is running and document the full endpoint surface in a single image.', + }); }); }); @@ -104,7 +169,6 @@ test.describe('Series 1 — Authentication', () => { await page.goto('/'); await settle(page); - // Open user menu and click login to reach IdentityServer const userIcon = page.locator( 'button[aria-label="User menu"], button mat-icon:has-text("account_circle"), header button:has(mat-icon)' ).last(); @@ -116,10 +180,16 @@ test.describe('Series 1 — Authentication', () => { ).first(); await loginOption.click(); - // Wait for IdentityServer login page await page.waitForSelector('input[name="Username"]', { timeout: 15000 }); await settle(page, 1000); - await shot(page, 'series-1-authentication', 'identityserver-login-form.png'); + + await shot(page, 'series-1-authentication', 'identityserver-login-form.png', { + description: + 'Duende IdentityServer 7.0 login page — shows the Username and Password fields and the Login button. Reached after clicking Login in the Angular user menu.', + articles: ['1.1'], + tags: ['identityserver', 'login', 'oauth2', 'oidc', 'authentication'], + useFor: 'Illustrate the OIDC redirect step in the OAuth 2.0 PKCE flow.', + }); }); test('user menu — anonymous state', async ({ page }) => { @@ -131,13 +201,27 @@ test.describe('Series 1 — Authentication', () => { ).last(); await userIcon.click(); await page.waitForTimeout(800); - await shot(page, 'series-1-authentication', 'user-menu-anonymous.png'); + + await shot(page, 'series-1-authentication', 'user-menu-anonymous.png', { + description: + 'Angular app header user menu expanded in anonymous state — shows only the "Login" option.', + articles: ['1.1'], + tags: ['user-menu', 'anonymous', 'header', 'login-button'], + useFor: 'Show where users click to initiate the OIDC login flow.', + }); }); test('dashboard after login — manager role', async ({ page }) => { await loginAsRole(page, 'manager'); await settle(page, 2000); - await shot(page, 'series-1-authentication', 'dashboard-authenticated.png', { fullPage: false }); + + await shot(page, 'series-1-authentication', 'dashboard-authenticated.png', { + description: + 'TalentManagement dashboard immediately after a successful OAuth 2.0 PKCE login as the Manager role — shows metrics cards, sidebar with manager-visible menu items, and authenticated user avatar.', + articles: ['1.1', '1.2', '1.4'], + tags: ['dashboard', 'authenticated', 'manager', 'post-login', 'oauth2'], + useFor: 'Hero image showing the successful result of the OIDC login flow.', + }); }); test('user menu — authenticated state', async ({ page }) => { @@ -149,30 +233,47 @@ test.describe('Series 1 — Authentication', () => { ).last(); await userIcon.click(); await page.waitForTimeout(800); - await shot(page, 'series-1-authentication', 'user-menu-authenticated.png'); + + await shot(page, 'series-1-authentication', 'user-menu-authenticated.png', { + description: + 'Angular app header user menu expanded after login — shows Profile, Settings, and Logout options alongside the authenticated username.', + articles: ['1.1', '1.2'], + tags: ['user-menu', 'authenticated', 'header', 'logout', 'profile'], + useFor: 'Show the post-login user menu options including Profile (for token inspection) and Logout.', + }); }); - test('role-based sidebar — HRAdmin sees all menu items', async ({ page }) => { + test('sidebar — HRAdmin full menu', async ({ page }) => { await loginAsRole(page, 'hradmin'); await settle(page); + const sidebar = page.locator('mat-sidenav, .matero-sidenav, app-sidebar, nav').first(); const box = await sidebar.boundingBox(); if (box) { await shot(page, 'series-1-authentication', 'sidebar-hradmin-full-menu.png', { - clip: { x: box.x, y: box.y, width: box.width, height: box.height }, - }); + description: + 'Sidebar navigation as seen by the HRAdmin role — all menu items visible including Positions, Salary Ranges, and AI Assistant.', + articles: ['1.4'], + tags: ['sidebar', 'hradmin', 'role-based-ui', 'menu', 'ngx-permissions'], + useFor: 'Contrast with the Manager sidebar to demonstrate role-based UI rendering via ngx-permissions.', + }, { clip: { x: box.x, y: box.y, width: box.width, height: box.height } }); } }); - test('role-based sidebar — manager sees limited menu', async ({ page }) => { + test('sidebar — manager limited menu', async ({ page }) => { await loginAsRole(page, 'manager'); await settle(page); + const sidebar = page.locator('mat-sidenav, .matero-sidenav, app-sidebar, nav').first(); const box = await sidebar.boundingBox(); if (box) { await shot(page, 'series-1-authentication', 'sidebar-manager-limited-menu.png', { - clip: { x: box.x, y: box.y, width: box.width, height: box.height }, - }); + description: + 'Sidebar navigation as seen by the Manager role — Positions and Salary Ranges are hidden; only Employee and Department management are visible.', + articles: ['1.4'], + tags: ['sidebar', 'manager', 'role-based-ui', 'menu', 'ngx-permissions'], + useFor: 'Demonstrate role-based menu hiding with ngx-permissions; pair with the HRAdmin sidebar for a before/after comparison.', + }, { clip: { x: box.x, y: box.y, width: box.width, height: box.height } }); } }); @@ -180,7 +281,6 @@ test.describe('Series 1 — Authentication', () => { await loginAsRole(page, 'manager'); await settle(page); - // Trigger logout const userIcon = page.locator( 'button[aria-label="User menu"], button mat-icon:has-text("account_circle"), header button:has(mat-icon)' ).last(); @@ -192,10 +292,16 @@ test.describe('Series 1 — Authentication', () => { ).first(); await logoutOption.click(); - // Wait for IdentityServer logout page await page.waitForTimeout(3000); await settle(page, 1000); - await shot(page, 'series-1-authentication', 'identityserver-logout-screen.png'); + + await shot(page, 'series-1-authentication', 'identityserver-logout-screen.png', { + description: + 'Duende IdentityServer logout confirmation screen with a "click here" link to return to the Angular app.', + articles: ['1.1'], + tags: ['identityserver', 'logout', 'oauth2', 'oidc'], + useFor: 'Illustrate the IdentityServer-managed logout redirect step.', + }); }); }); @@ -206,35 +312,66 @@ test.describe('Series 1 — Authentication', () => { test.describe('Series 2 — .NET API', () => { const swaggerBase = APP_URLS.api.replace('/api/v1', '') + '/swagger'; - test('swagger UI — full overview', async ({ page }) => { + test('swagger — employees endpoints expanded', async ({ page }) => { await page.goto(swaggerBase, { waitUntil: 'networkidle', timeout: 15000 }); await settle(page, 2000); - await shot(page, 'series-2-dotnet-api', 'swagger-full-overview.png', { fullPage: false }); + + const employeesSection = page.locator('.opblock-tag-section, .opblock-tag').filter({ hasText: /employee/i }).first(); + if (await employeesSection.count() > 0) { + await employeesSection.click(); + await page.waitForTimeout(1000); + } + + await shot(page, 'series-2-dotnet-api', 'swagger-employees-endpoints.png', { + description: + 'Swagger UI with the Employees controller expanded — shows GET, POST, PUT, DELETE endpoints with versioning and JWT lock icons indicating auth-protected routes.', + articles: ['2.1', '2.3', '2.4'], + tags: ['swagger', 'api', 'employees', 'crud', 'versioning', 'jwt'], + useFor: 'Document the employee CRUD API surface; show JWT auth requirement on protected endpoints.', + }); }); - test('swagger UI — employees endpoints expanded', async ({ page }) => { + test('swagger — AI endpoints expanded', async ({ page }) => { await page.goto(swaggerBase, { waitUntil: 'networkidle', timeout: 15000 }); await settle(page, 2000); - // Expand the Employees section - const employeesSection = page.locator('.opblock-tag-section, .opblock-tag').filter({ hasText: /employee/i }).first(); - if (await employeesSection.count() > 0) { - await employeesSection.click(); + const aiSection = page.locator('.opblock-tag-section, .opblock-tag').filter({ hasText: /^ai$/i }).first(); + if (await aiSection.count() > 0) { + await aiSection.click(); await page.waitForTimeout(1000); + + await shot(page, 'series-2-dotnet-api', 'swagger-ai-endpoints.png', { + description: + 'Swagger UI with the AI controller expanded — shows POST /ai/chat, POST /ai/hr-insight, and POST /ai/nl-employee-search endpoints.', + articles: ['6.1', '6.2', '6.5'], + tags: ['swagger', 'api', 'ai', 'chat', 'hr-insight', 'ollama'], + useFor: 'Show the AI endpoint surface available after enabling AiEnabled feature flag.', + }); } - await shot(page, 'series-2-dotnet-api', 'swagger-employees-endpoints.png', { fullPage: false }); }); - test('swagger UI — AI endpoints expanded', async ({ page }) => { + test('swagger — ai/chat try-it-out expanded', async ({ page }) => { await page.goto(swaggerBase, { waitUntil: 'networkidle', timeout: 15000 }); await settle(page, 2000); - // Expand the AI section const aiSection = page.locator('.opblock-tag-section, .opblock-tag').filter({ hasText: /^ai$/i }).first(); if (await aiSection.count() > 0) { await aiSection.click(); await page.waitForTimeout(1000); - await shot(page, 'series-2-dotnet-api', 'swagger-ai-endpoints.png', { fullPage: false }); + + const chatEndpoint = page.locator('.opblock-post').filter({ hasText: /\/ai\/chat/i }).first(); + if (await chatEndpoint.count() > 0) { + await chatEndpoint.click(); + await page.waitForTimeout(800); + + await shot(page, 'series-2-dotnet-api', 'swagger-ai-chat-endpoint.png', { + description: + 'Swagger UI showing the POST /api/v1/ai/chat endpoint expanded with its request body schema (message, systemPrompt fields).', + articles: ['6.1'], + tags: ['swagger', 'ai', 'chat', 'endpoint', 'request-body'], + useFor: 'Illustrate how to test the AI chat endpoint directly from Swagger UI in Article 6.1.', + }); + } } }); }); @@ -247,29 +384,49 @@ test.describe('Series 3 — Angular Material', () => { test('dashboard — metrics cards and charts', async ({ page }) => { await loginAsRole(page, 'manager'); await page.goto('/dashboard'); - await settle(page, 3000); // extra time for charts to render - await shot(page, 'series-3-angular-material', 'dashboard-metrics-charts.png', { fullPage: true }); + await settle(page, 3000); + + await shot(page, 'series-3-angular-material', 'dashboard-metrics-charts.png', { + description: + 'TalentManagement dashboard showing KPI metric cards (total employees, departments, new hires) and Chart.js bar/doughnut charts for department and gender distribution.', + articles: ['3.1', '3.4', '6.4'], + tags: ['dashboard', 'charts', 'metrics', 'angular-material', 'chartjs'], + useFor: 'Hero image for dashboard and Chart.js articles; also the base screenshot for AI insights overlay comparison in Series 6.', + }, { fullPage: true }); }); - test('employee list — data table with pagination', async ({ page }) => { + test('employee list — data table', async ({ page }) => { await loginAsRole(page, 'manager'); await page.goto('/employees'); await settle(page, 2000); - await shot(page, 'series-3-angular-material', 'employee-list-table.png', { fullPage: false }); + + await shot(page, 'series-3-angular-material', 'employee-list-table.png', { + description: + 'Angular Material data table listing employees with sortable columns (name, department, position, hire date), pagination controls, and a search/filter bar.', + articles: ['3.1', '6.5'], + tags: ['employee-list', 'data-table', 'angular-material', 'pagination', 'sorting'], + useFor: 'Illustrate the Material Design data table component and the employee list feature.', + }); }); - test('employee create form — dialog open', async ({ page }) => { + test('employee create form — dialog', async ({ page }) => { await loginAsRole(page, 'manager'); await page.goto('/employees'); await settle(page, 1500); - // Open create dialog const createBtn = page.locator('button').filter({ hasText: /create|add/i }).first(); if (await createBtn.count() > 0) { await createBtn.click(); await page.waitForSelector('mat-dialog-container, .mat-dialog-container, form', { timeout: 5000 }); await page.waitForTimeout(800); - await shot(page, 'series-3-angular-material', 'employee-create-form.png'); + + await shot(page, 'series-3-angular-material', 'employee-create-form.png', { + description: + 'Angular Material dialog showing the Create Employee reactive form with fields for name, email, department, position, hire date, and gender — with inline validation.', + articles: ['3.2', '3.3'], + tags: ['employee-form', 'reactive-forms', 'mat-dialog', 'validation', 'angular-material'], + useFor: 'Illustrate the reactive form inside a Material dialog for the forms and dialogs articles.', + }); } }); @@ -277,21 +434,42 @@ test.describe('Series 3 — Angular Material', () => { await loginAsRole(page, 'manager'); await page.goto('/departments'); await settle(page, 2000); - await shot(page, 'series-3-angular-material', 'department-list-table.png', { fullPage: false }); + + await shot(page, 'series-3-angular-material', 'department-list-table.png', { + description: + 'Department management page showing a Material data table with department names and action buttons (edit, delete) accessible to Manager and HRAdmin roles.', + articles: ['3.1'], + tags: ['department-list', 'data-table', 'angular-material', 'crud'], + useFor: 'Illustrate the department management feature alongside the employee list.', + }); }); test('position list — HRAdmin only', async ({ page }) => { await loginAsRole(page, 'hradmin'); await page.goto('/positions'); await settle(page, 2000); - await shot(page, 'series-3-angular-material', 'position-list-table.png', { fullPage: false }); + + await shot(page, 'series-3-angular-material', 'position-list-table.png', { + description: + 'Position management page visible only to the HRAdmin role — shows a Material table of job positions with title, department, and salary range columns.', + articles: ['1.4', '3.1'], + tags: ['position-list', 'hradmin', 'role-based-ui', 'data-table'], + useFor: 'Demonstrate HRAdmin-only feature access; useful for role-based UI articles.', + }); }); test('salary ranges — HRAdmin only', async ({ page }) => { await loginAsRole(page, 'hradmin'); await page.goto('/salary-ranges'); await settle(page, 2000); - await shot(page, 'series-3-angular-material', 'salary-ranges-table.png', { fullPage: false }); + + await shot(page, 'series-3-angular-material', 'salary-ranges-table.png', { + description: + 'Salary Range management page restricted to HRAdmin — shows a table with range label, minimum, and maximum salary columns.', + articles: ['1.4', '3.1'], + tags: ['salary-ranges', 'hradmin', 'role-based-ui', 'data-table'], + useFor: 'Show the HRAdmin-exclusive salary range management feature.', + }); }); }); @@ -300,156 +478,153 @@ test.describe('Series 3 — Angular Material', () => { // --------------------------------------------------------------------------- test.describe('Series 6 — AI Features', () => { - test.beforeEach(async ({ page }) => { - // Skip entire group gracefully if AI is disabled in the environment - // (We check by navigating to /ai-chat and seeing if content loads) - }); - - test('dashboard — AI insights card loaded', async ({ page }) => { + test('dashboard — AI insights card', async ({ page }) => { await loginAsRole(page, 'manager'); await page.goto('/dashboard'); await settle(page, 2000); - // Check if AI insights card is present (only when aiEnabled: true) const aiCard = page.locator('.ai-insights-card, mat-card:has(mat-icon:has-text("smart_toy"))').first(); const hasAiCard = await aiCard.count() > 0; if (hasAiCard) { - // Wait for AI insight text to appear (Ollama can take 5-15s) + // Wait for AI text to arrive from Ollama (up to 30s) await page.waitForSelector('.ai-insight-text, .ai-insights-card p', { timeout: 30000 }) - .catch(() => { /* AI may be disabled or loading — screenshot either state */ }); + .catch(() => {}); await page.waitForTimeout(1000); - await shot(page, 'series-6-ai-app-features', 'dashboard-ai-insights-card.png', { fullPage: false }); - // Crop just the AI card for a close-up + await shot(page, 'series-6-ai-app-features', 'dashboard-ai-insights-card.png', { + description: + 'TalentManagement dashboard with the AI Insights mat-card at the top — shows an LLM-generated plain-English executive summary of live workforce metrics produced by Ollama.', + articles: ['6.4'], + tags: ['dashboard', 'ai-insights', 'ollama', 'executive-summary', 'angular-material'], + useFor: 'Hero image for Article 6.4; shows the AI card in context above the metric cards.', + }); + const box = await aiCard.boundingBox(); if (box) { await shot(page, 'series-6-ai-app-features', 'dashboard-ai-insights-card-closeup.png', { - clip: { x: box.x, y: box.y, width: box.width, height: Math.min(box.height, 300) }, - }); + description: + 'Close-up of the AI Insights mat-card — smart_toy icon, "AI Workforce Insights" title, and the generated executive summary text.', + articles: ['6.4'], + tags: ['dashboard', 'ai-insights', 'mat-card', 'closeup', 'smart-toy-icon'], + useFor: 'Use as an inline image in Article 6.4 step-by-step walkthrough to show the finished card component.', + }, { clip: { x: box.x, y: box.y, width: box.width, height: Math.min(box.height, 300) } }); } } else { - // Screenshot dashboard anyway — documents the disabled state - await shot(page, 'series-6-ai-app-features', 'dashboard-ai-disabled-state.png', { fullPage: false }); + await shot(page, 'series-6-ai-app-features', 'dashboard-ai-disabled-state.png', { + description: + 'Dashboard without the AI insights card — the state when aiEnabled is false in environment.ts. Original dashboard is completely unaffected by the AI feature flag.', + articles: ['6.4'], + tags: ['dashboard', 'ai-disabled', 'feature-flag', 'graceful-degradation'], + useFor: 'Show the before state (AI disabled) before revealing the after state (AI enabled) in Article 6.4.', + }); } }); - test('AI chat widget — disabled banner (aiEnabled: false)', async ({ page }) => { + test('AI chat widget — full page', async ({ page }) => { await loginAsRole(page, 'manager'); await page.goto('/ai-chat'); await settle(page, 1500); - // Capture whatever state we're in — could be the info banner or the live chat - await shot(page, 'series-6-ai-app-features', 'ai-chat-page-full.png', { fullPage: true }); + await shot(page, 'series-6-ai-app-features', 'ai-chat-page-full.png', { + description: + 'Full AI Assistant page (/ai-chat) — either shows the two-tab chat UI (when aiEnabled: true) or an info banner explaining how to enable AI features (when false).', + articles: ['6.3'], + tags: ['ai-chat', 'angular-material', 'mat-tab-group', 'feature-flag'], + useFor: 'Hero image for Article 6.3 showing the complete chat page layout.', + }, { fullPage: true }); }); - test('AI chat — Tab 1 general chat with a reply', async ({ page }) => { + test('AI chat — Tab 1 with reply', async ({ page }) => { await loginAsRole(page, 'manager'); await page.goto('/ai-chat'); await settle(page, 1500); - // Check if tabs exist (only when aiEnabled: true) const tabGroup = page.locator('mat-tab-group').first(); - const hasTabs = await tabGroup.count() > 0; - - if (!hasTabs) { - // AI disabled — screenshot the info banner - await shot(page, 'series-6-ai-app-features', 'ai-chat-disabled-banner.png', { fullPage: false }); + if (await tabGroup.count() === 0) { + await shot(page, 'series-6-ai-app-features', 'ai-chat-disabled-banner.png', { + description: + 'AI Assistant page showing the info banner when aiEnabled is false — explains that Ollama must be running and AiEnabled set to true.', + 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.', + }); return; } - // Tab 1 is General Chat — it should be active by default - await shot(page, 'series-6-ai-app-features', 'ai-chat-tab1-general-empty.png', { fullPage: false }); + await shot(page, 'series-6-ai-app-features', 'ai-chat-tab1-general-empty.png', { + description: + 'AI Assistant page, Tab 1 (General Chat) — empty state showing the text input and Send button before any messages are sent.', + articles: ['6.3'], + tags: ['ai-chat', 'general-chat', 'tab1', 'empty-state', 'angular-material'], + useFor: 'Show the initial empty chat state at the beginning of Article 6.3.', + }); - // Type a question and send - const input = page.locator('mat-tab-body:first-child textarea, mat-tab-body:first-child input[type="text"]').first(); + const input = page.locator('mat-tab-body textarea, mat-tab-body 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'); - // Wait for the AI reply (Ollama can take up to 15s) await page.waitForSelector( - 'mat-tab-body:first-child .assistant, mat-tab-body:first-child [class*="assistant"]', + '[class*="assistant"], .message-assistant, .chat-message', { timeout: 30000 } - ).catch(() => { /* capture whatever state loaded */ }); + ).catch(() => {}); await page.waitForTimeout(2000); - await shot(page, 'series-6-ai-app-features', 'ai-chat-tab1-with-reply.png', { fullPage: false }); + + await shot(page, 'series-6-ai-app-features', 'ai-chat-tab1-with-reply.png', { + description: + 'AI Assistant Tab 1 (General Chat) showing a user question about OAuth 2.0 vs OIDC and Ollama\'s reply — demonstrates the conversation history layout with user/assistant message bubbles.', + articles: ['6.3'], + tags: ['ai-chat', 'general-chat', 'tab1', 'ollama-reply', 'conversation'], + useFor: 'Show the live chat in action in Article 6.3.', + }); } }); - test('AI chat — Tab 2 HR insights with a data-grounded answer', async ({ page }) => { + test('AI chat — Tab 2 HR insights with answer', async ({ page }) => { await loginAsRole(page, 'manager'); await page.goto('/ai-chat'); await settle(page, 1500); const tabGroup = page.locator('mat-tab-group').first(); - if (await tabGroup.count() === 0) { - return; // AI disabled — skip - } + if (await tabGroup.count() === 0) return; - // Switch to Tab 2 (HR Insights) const hrTab = page.locator('[role="tab"]').filter({ hasText: /hr insight/i }).first(); - if (await hrTab.count() > 0) { - await hrTab.click(); - await page.waitForTimeout(800); - await shot(page, 'series-6-ai-app-features', 'ai-chat-tab2-hr-insights-empty.png', { fullPage: false }); - - // Click a suggestion button (pre-filled question) - const suggestionBtn = page.locator('mat-tab-body:nth-child(2) button[mat-stroked-button], mat-tab-body:nth-child(2) button[mat-flat-button]').first(); - if (await suggestionBtn.count() > 0) { - await suggestionBtn.click(); - } else { - // Fallback: type a question manually - const input = page.locator('mat-tab-body:nth-child(2) textarea, mat-tab-body:nth-child(2) input').first(); - if (await input.count() > 0) { - await input.fill('Which department has the most employees?'); - await page.keyboard.press('Enter'); - } - } - - // Wait for data-grounded reply - await page.waitForTimeout(30000).catch(() => {}); - await shot(page, 'series-6-ai-app-features', 'ai-chat-tab2-hr-insights-with-answer.png', { fullPage: false }); - } - }); + if (await hrTab.count() === 0) return; - test('AI chat — suggestion buttons visible', async ({ page }) => { - await loginAsRole(page, 'manager'); - await page.goto('/ai-chat'); - await settle(page, 1500); + await hrTab.click(); + await page.waitForTimeout(800); - const hrTab = page.locator('[role="tab"]').filter({ hasText: /hr insight/i }).first(); - if (await hrTab.count() > 0) { - await hrTab.click(); - await page.waitForTimeout(800); + await shot(page, 'series-6-ai-app-features', 'ai-chat-tab2-hr-insights-empty.png', { + description: + 'AI Assistant Tab 2 (HR Insights) in empty state — shows the four pre-filled suggestion buttons and text input before any question is asked.', + articles: ['6.3'], + tags: ['ai-chat', 'hr-insights', 'tab2', 'suggestion-buttons', 'empty-state'], + useFor: 'Show the HR Insights tab layout including the suggestion buttons in Article 6.3.', + }); - // Capture the suggestion buttons row - const buttons = page.locator('mat-tab-body:nth-child(2) button').first(); - const box = await buttons.boundingBox(); - if (box) { - await shot(page, 'series-6-ai-app-features', 'ai-chat-suggestion-buttons.png', { fullPage: false }); + // Click first suggestion or type a question + const suggestionBtn = page.locator('mat-tab-body button[mat-stroked-button], mat-tab-body button[mat-flat-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'); } } - }); - test('swagger — AI chat endpoint try-it-out', async ({ page }) => { - const swaggerBase = APP_URLS.api.replace('/api/v1', '') + '/swagger'; - await page.goto(swaggerBase, { waitUntil: 'networkidle', timeout: 15000 }); - await settle(page, 2000); - - // Expand AI section - const aiSection = page.locator('.opblock-tag-section, .opblock-tag').filter({ hasText: /^ai$/i }).first(); - if (await aiSection.count() > 0) { - await aiSection.click(); - await page.waitForTimeout(1000); + // Wait for Ollama (up to 30s) + await page.waitForTimeout(30000).catch(() => {}); + await page.waitForTimeout(1000); - // Expand the chat POST endpoint - const chatEndpoint = page.locator('.opblock-post').filter({ hasText: /\/ai\/chat/i }).first(); - if (await chatEndpoint.count() > 0) { - await chatEndpoint.click(); - await page.waitForTimeout(800); - await shot(page, 'series-6-ai-app-features', 'swagger-ai-chat-endpoint.png', { fullPage: false }); - } - } + await shot(page, 'series-6-ai-app-features', 'ai-chat-tab2-hr-insights-with-answer.png', { + description: + 'AI Assistant Tab 2 (HR Insights) showing a data-grounded answer from Ollama — the reply references actual department headcounts from the live database rather than hallucinated figures. Execution time shown below the answer.', + articles: ['6.2', '6.3'], + tags: ['ai-chat', 'hr-insights', 'tab2', 'rag', 'grounded-answer', 'execution-time'], + useFor: 'Key proof-of-concept image for Article 6.2 and 6.3 — shows that the AI answer is grounded in real workforce data, not hallucinated.', + }); }); }); From e826a313ad241359b74a5d8c421d349caed6782a Mon Sep 17 00:00:00 2001 From: Fuji Nguyen Date: Mon, 20 Apr 2026 21:54:12 -0400 Subject: [PATCH 03/10] Add PowerShell TTS voiceover and narration metadata to screenshot catalog --- scripts/speak.ps1 | 92 ++++++ tests/screenshots/blog-screenshots.spec.ts | 307 ++++++++++++++------- 2 files changed, 298 insertions(+), 101 deletions(-) create mode 100644 scripts/speak.ps1 diff --git a/scripts/speak.ps1 b/scripts/speak.ps1 new file mode 100644 index 0000000..cf4e1d0 --- /dev/null +++ b/scripts/speak.ps1 @@ -0,0 +1,92 @@ +<# +.SYNOPSIS + Generates a WAV audio file from text using Windows built-in TTS. + +.DESCRIPTION + Uses System.Speech.Synthesis.SpeechSynthesizer (built into Windows — no + external API key or installation required) to convert a narration string + into a WAV file. + + Called by the Playwright screenshots spec after each page.screenshot() call + so that every blog screenshot has a matching narration audio file. + +.PARAMETER Text + The narration text to speak. Keep to 1-3 sentences for best pacing. + +.PARAMETER OutputPath + Full path for the output WAV file (e.g. screenshots-output\series-1\dashboard.wav). + Parent directory must already exist. + +.PARAMETER Voice + Optional. Name of an installed Windows TTS voice. + Run: Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).GetInstalledVoices() | Select -ExpandProperty VoiceInfo | Select Name + Defaults to the system default voice. + +.PARAMETER Rate + Speech rate: -10 (slowest) to 10 (fastest). Default: -1 (slightly slower + than default for clearer narration in video). + +.PARAMETER Volume + Volume: 0-100. Default: 100. + +.EXAMPLE + .\speak.ps1 -Text "The dashboard loads immediately after login." -OutputPath "screenshots-output\series-1\dashboard.wav" + +.EXAMPLE + .\speak.ps1 -Text "Here we can see the AI insights card." -OutputPath "out.wav" -Voice "Microsoft David Desktop" -Rate -2 +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string] $Text, + + [Parameter(Mandatory = $true)] + [string] $OutputPath, + + [string] $Voice = "", + [int] $Rate = -1, + [int] $Volume = 100 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +try { + Add-Type -AssemblyName System.Speech + + $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer + + # Select voice if specified; otherwise use system default + if ($Voice -ne "") { + try { + $synth.SelectVoice($Voice) + } catch { + Write-Warning "Voice '$Voice' not found — using system default. Available voices:" + $synth.GetInstalledVoices() | ForEach-Object { + Write-Warning (" " + $_.VoiceInfo.Name) + } + } + } + + $synth.Rate = [Math]::Max(-10, [Math]::Min(10, $Rate)) + $synth.Volume = [Math]::Max(0, [Math]::Min(100, $Volume)) + + # Ensure output directory exists + $dir = Split-Path -Parent $OutputPath + if ($dir -and -not (Test-Path $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + + $synth.SetOutputToWaveFile($OutputPath) + $synth.Speak($Text) + $synth.SetOutputToDefaultAudioDevice() # reset so the object is reusable + $synth.Dispose() + + Write-Host "Audio saved: $OutputPath" + exit 0 + +} catch { + Write-Error "speak.ps1 failed: $_" + exit 1 +} diff --git a/tests/screenshots/blog-screenshots.spec.ts b/tests/screenshots/blog-screenshots.spec.ts index 11428b2..c94eec6 100644 --- a/tests/screenshots/blog-screenshots.spec.ts +++ b/tests/screenshots/blog-screenshots.spec.ts @@ -2,28 +2,30 @@ * Blog Screenshots * * Captures key UI states from the TalentManagement app for use in blog posts - * and documentation. Each screenshot is registered in screenshot-catalog.json - * so AI tools can reference images by description when writing blog articles. + * and documentation. Each screenshot: + * - Is saved as a PNG in screenshots-output/{series}/ + * - Gets a matching WAV narration file in the same folder (Windows TTS via speak.ps1) + * - Is registered in screenshot-catalog.json for AI-assisted blog writing * * Usage: * npx playwright test --project=screenshots * * Output: - * screenshots-output/ ← PNG files organised by series - * screenshot-catalog.json ← Machine-readable index (regenerated each run) + * screenshots-output/series-x/filename.png ← screenshot + * screenshots-output/series-x/filename.wav ← narration audio (Windows only) + * screenshot-catalog.json ← machine-readable index * - * Prerequisites: All three services must be running: + * Prerequisites: * - Angular: http://localhost:4200 * - .NET API: https://localhost:44378 * - IdentityServer: https://localhost:44310 - * - * For Series 6 AI screenshots: set AiEnabled: true in API appsettings.json - * and aiEnabled: true in Angular environment.ts + * - For Series 6: Ollama running + AiEnabled: true + aiEnabled: true in environment.ts */ import { test, Page } from '@playwright/test'; import * as fs from 'fs'; import * as path from 'path'; +import { spawnSync } from 'child_process'; import { loginAsRole } from '../../fixtures/auth.fixtures'; import { APP_URLS } from '../../config/test-config'; @@ -32,8 +34,10 @@ import { APP_URLS } from '../../config/test-config'; // --------------------------------------------------------------------------- interface ScreenshotMeta { - /** One or two sentence description of what the screenshot shows. Used by AI when selecting images for blog posts. */ + /** Technical description — what the screenshot shows (used in the catalog for AI lookup). */ description: string; + /** Conversational narration script spoken aloud via TTS when the screenshot is taken. 1-3 short sentences. */ + narration: string; /** Blog article numbers this screenshot is relevant to (e.g. ["1.1", "1.2"]) */ articles: string[]; /** Keywords for filtering (e.g. ["dashboard", "authentication", "manager"]) */ @@ -43,10 +47,11 @@ interface ScreenshotMeta { } interface CatalogEntry extends ScreenshotMeta { - path: string; // relative path from repo root: screenshots-output/series-x/filename.png - series: string; // e.g. "series-1-authentication" - filename: string; // e.g. "dashboard-authenticated.png" - capturedAt: string; // ISO timestamp + path: string; // e.g. "screenshots-output/series-1-authentication/dashboard.png" + audioPath: string; // e.g. "screenshots-output/series-1-authentication/dashboard.wav" + series: string; + filename: string; + capturedAt: string; } interface Catalog { @@ -55,13 +60,22 @@ interface Catalog { } // --------------------------------------------------------------------------- -// Catalog writer (accumulates entries across tests in a single run) +// Config // --------------------------------------------------------------------------- -const OUTPUT_ROOT = path.join(__dirname, '..', '..', 'screenshots-output'); +const OUTPUT_ROOT = path.join(__dirname, '..', '..', 'screenshots-output'); const CATALOG_PATH = path.join(__dirname, '..', '..', 'screenshot-catalog.json'); +const SPEAK_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'speak.ps1'); + +// Set SCREENSHOTS_VOICE env var to choose a Windows TTS voice, e.g: +// $env:SCREENSHOTS_VOICE = "Microsoft David Desktop" +const TTS_VOICE = process.env.SCREENSHOTS_VOICE ?? ''; +const TTS_RATE = parseInt(process.env.SCREENSHOTS_RATE ?? '-1', 10); + +// --------------------------------------------------------------------------- +// Catalog writer +// --------------------------------------------------------------------------- -// Load existing catalog (if present) so we can merge/overwrite individual entries let catalog: Catalog = { generated: new Date().toISOString(), screenshots: [] }; function saveCatalog(): void { @@ -69,7 +83,39 @@ function saveCatalog(): void { } // --------------------------------------------------------------------------- -// shot() — take screenshot + register catalog entry +// TTS — call speak.ps1 via PowerShell (Windows only; skipped silently on other OS) +// --------------------------------------------------------------------------- + +function generateAudio(text: string, outputWav: string): void { + if (process.platform !== 'win32') { + return; // TTS is Windows-only; skip on macOS/Linux CI + } + if (!fs.existsSync(SPEAK_SCRIPT)) { + console.warn(`speak.ps1 not found at ${SPEAK_SCRIPT} — skipping audio`); + return; + } + + const args = [ + '-NoProfile', + '-ExecutionPolicy', 'Bypass', + '-File', SPEAK_SCRIPT, + '-Text', text, + '-OutputPath', outputWav, + '-Rate', String(TTS_RATE), + ]; + if (TTS_VOICE) { + args.push('-Voice', TTS_VOICE); + } + + const result = spawnSync('powershell.exe', args, { encoding: 'utf-8' }); + + if (result.status !== 0) { + console.warn(`speak.ps1 exited ${result.status}: ${result.stderr ?? result.stdout}`); + } +} + +// --------------------------------------------------------------------------- +// shot() — screenshot + audio + catalog entry // --------------------------------------------------------------------------- async function shot( @@ -82,23 +128,33 @@ async function shot( clip?: { x: number; y: number; width: number; height: number }; } = {} ): Promise { - const dir = path.join(OUTPUT_ROOT, series); + const dir = path.join(OUTPUT_ROOT, series); + const baseName = path.basename(filename, '.png'); + const pngPath = path.join(dir, filename); + const wavPath = path.join(dir, `${baseName}.wav`); + fs.mkdirSync(dir, { recursive: true }); - const filePath = path.join(dir, filename); + // 1. Take screenshot await page.screenshot({ - path: filePath, + path: pngPath, fullPage: options.fullPage ?? false, clip: options.clip, }); - // Upsert catalog entry (replace if same series+filename already exists) - const relativePath = `screenshots-output/${series}/${filename}`; + // 2. Generate narration audio (Windows TTS) + generateAudio(meta.narration, wavPath); + + // 3. Upsert catalog entry + const relativePng = `screenshots-output/${series}/${filename}`; + const relativeWav = `screenshots-output/${series}/${baseName}.wav`; + catalog.screenshots = catalog.screenshots.filter( e => !(e.series === series && e.filename === filename) ); catalog.screenshots.push({ - path: relativePath, + path: relativePng, + audioPath: relativeWav, series, filename, capturedAt: new Date().toISOString(), @@ -124,10 +180,12 @@ test.describe('Series 0 — Architecture', () => { await shot(page, 'series-0-architecture', 'anonymous-home.png', { description: - 'Full TalentManagement app in anonymous/Guest state — shows the sidebar with limited menu items, the header with the user icon, and an empty dashboard placeholder.', + 'Full TalentManagement app in anonymous Guest state — sidebar with limited menu, header with user icon, empty dashboard placeholder.', + narration: + 'This is the TalentManagement application before login. The sidebar shows only public routes, and the header displays a Guest user icon in the top right corner.', articles: ['0.1', '0.2', '1.1'], tags: ['anonymous', 'guest', 'home', 'sidebar', 'app-shell'], - useFor: 'Hero image for architecture overview articles; illustrates the app before login.', + useFor: 'Hero image for architecture overview articles; shows the app before login.', }); const sidebar = page.locator('mat-sidenav, .matero-sidenav, app-sidebar, nav').first(); @@ -135,10 +193,12 @@ test.describe('Series 0 — Architecture', () => { if (box) { await shot(page, 'series-0-architecture', 'sidebar-navigation.png', { description: - 'Left sidebar showing the navigation menu items available to an anonymous (Guest) user — limited to public routes only.', + 'Left sidebar showing navigation menu items available to an anonymous Guest user — limited to public routes only.', + narration: + 'The sidebar navigation shows a limited set of menu items for anonymous users. After login, additional items appear based on the user\'s assigned role.', articles: ['0.1', '1.4'], tags: ['sidebar', 'navigation', 'anonymous', 'menu'], - useFor: 'Illustrate the sidebar structure before discussing role-based menu visibility.', + useFor: 'Illustrate sidebar structure before discussing role-based menu visibility.', }, { clip: { x: box.x, y: box.y, width: box.width, height: box.height } }); } }); @@ -152,10 +212,12 @@ test.describe('Series 0 — Architecture', () => { await shot(page, 'series-0-architecture', 'swagger-ui-overview.png', { description: - 'NSwag/Swagger UI for the TalentManagement .NET 10 Web API, showing all versioned controller groups (Employees, Departments, Positions, AI, etc.) collapsed.', + 'NSwag Swagger UI for the TalentManagement .NET 10 Web API — all versioned controller groups collapsed.', + narration: + 'The Swagger UI confirms the .NET 10 Web API is running on port 44378. You can see all the controller groups — Employees, Departments, Positions, and the AI endpoints added in Series 6.', articles: ['0.1', '2.1', '2.4', '6.1'], tags: ['swagger', 'api', 'dotnet', 'nswag', 'overview'], - useFor: 'Show the API is running and document the full endpoint surface in a single image.', + useFor: 'Show the API is running and document the full endpoint surface.', }); }); }); @@ -185,9 +247,11 @@ test.describe('Series 1 — Authentication', () => { await shot(page, 'series-1-authentication', 'identityserver-login-form.png', { description: - 'Duende IdentityServer 7.0 login page — shows the Username and Password fields and the Login button. Reached after clicking Login in the Angular user menu.', + 'Duende IdentityServer 7.0 login page — Username and Password fields, Login button. Reached after clicking Login in the Angular user menu.', + narration: + 'Clicking Login redirects to Duende IdentityServer — the token service in our CAT architecture. Enter your username and password here to receive an ID token and access token via the OAuth 2.0 PKCE flow.', articles: ['1.1'], - tags: ['identityserver', 'login', 'oauth2', 'oidc', 'authentication'], + tags: ['identityserver', 'login', 'oauth2', 'oidc', 'pkce'], useFor: 'Illustrate the OIDC redirect step in the OAuth 2.0 PKCE flow.', }); }); @@ -204,7 +268,9 @@ test.describe('Series 1 — Authentication', () => { await shot(page, 'series-1-authentication', 'user-menu-anonymous.png', { description: - 'Angular app header user menu expanded in anonymous state — shows only the "Login" option.', + 'Angular app header user menu expanded in anonymous state — shows only the Login option.', + narration: + 'Clicking the user icon in the top right opens a dropdown with a single Login option. This is where users start the OAuth 2.0 login flow.', articles: ['1.1'], tags: ['user-menu', 'anonymous', 'header', 'login-button'], useFor: 'Show where users click to initiate the OIDC login flow.', @@ -217,7 +283,9 @@ test.describe('Series 1 — Authentication', () => { await shot(page, 'series-1-authentication', 'dashboard-authenticated.png', { description: - 'TalentManagement dashboard immediately after a successful OAuth 2.0 PKCE login as the Manager role — shows metrics cards, sidebar with manager-visible menu items, and authenticated user avatar.', + 'TalentManagement dashboard after successful OAuth 2.0 PKCE login as Manager — metrics cards, sidebar with manager-visible items, authenticated user avatar in header.', + narration: + 'After a successful login, IdentityServer redirects back to the Angular app with an access token. The dashboard loads with live workforce metrics, and the sidebar now shows the Manager\'s available features.', articles: ['1.1', '1.2', '1.4'], tags: ['dashboard', 'authenticated', 'manager', 'post-login', 'oauth2'], useFor: 'Hero image showing the successful result of the OIDC login flow.', @@ -236,10 +304,12 @@ test.describe('Series 1 — Authentication', () => { await shot(page, 'series-1-authentication', 'user-menu-authenticated.png', { description: - 'Angular app header user menu expanded after login — shows Profile, Settings, and Logout options alongside the authenticated username.', + 'Angular app header user menu after login — shows Profile, Settings, and Logout options alongside the authenticated username.', + narration: + 'Once logged in, the user menu expands to show Profile, Settings, and Logout. The Profile page is particularly useful for inspecting the ID token and access token returned by IdentityServer.', articles: ['1.1', '1.2'], tags: ['user-menu', 'authenticated', 'header', 'logout', 'profile'], - useFor: 'Show the post-login user menu options including Profile (for token inspection) and Logout.', + useFor: 'Show the post-login user menu options including Profile and Logout.', }); }); @@ -252,10 +322,12 @@ test.describe('Series 1 — Authentication', () => { if (box) { await shot(page, 'series-1-authentication', 'sidebar-hradmin-full-menu.png', { description: - 'Sidebar navigation as seen by the HRAdmin role — all menu items visible including Positions, Salary Ranges, and AI Assistant.', + 'Sidebar as seen by HRAdmin role — all menu items visible including Positions, Salary Ranges, and AI Assistant.', + narration: + 'Logged in as HRAdmin, the sidebar shows the complete menu including Positions, Salary Ranges, and the AI Assistant — features restricted to the administrator role using ngx-permissions.', articles: ['1.4'], tags: ['sidebar', 'hradmin', 'role-based-ui', 'menu', 'ngx-permissions'], - useFor: 'Contrast with the Manager sidebar to demonstrate role-based UI rendering via ngx-permissions.', + useFor: 'Contrast with the Manager sidebar to demonstrate role-based UI rendering.', }, { clip: { x: box.x, y: box.y, width: box.width, height: box.height } }); } }); @@ -269,10 +341,12 @@ test.describe('Series 1 — Authentication', () => { if (box) { await shot(page, 'series-1-authentication', 'sidebar-manager-limited-menu.png', { description: - 'Sidebar navigation as seen by the Manager role — Positions and Salary Ranges are hidden; only Employee and Department management are visible.', + 'Sidebar as seen by Manager role — Positions and Salary Ranges hidden; only Employee and Department management visible.', + narration: + 'As a Manager, the sidebar shows only Employee and Department management. Positions and Salary Ranges are hidden — ngx-permissions reads the roles claim from the access token and removes those menu items automatically.', articles: ['1.4'], tags: ['sidebar', 'manager', 'role-based-ui', 'menu', 'ngx-permissions'], - useFor: 'Demonstrate role-based menu hiding with ngx-permissions; pair with the HRAdmin sidebar for a before/after comparison.', + useFor: 'Pair with the HRAdmin sidebar for a before/after role comparison.', }, { clip: { x: box.x, y: box.y, width: box.width, height: box.height } }); } }); @@ -291,15 +365,16 @@ test.describe('Series 1 — Authentication', () => { 'button:has-text("Logout"), a:has-text("Logout"), [role="menuitem"]:has-text("Logout")' ).first(); await logoutOption.click(); - await page.waitForTimeout(3000); await settle(page, 1000); await shot(page, 'series-1-authentication', 'identityserver-logout-screen.png', { description: 'Duende IdentityServer logout confirmation screen with a "click here" link to return to the Angular app.', + narration: + 'Logout is handled by IdentityServer, not Angular. This screen confirms the session has been terminated. Clicking the link returns to the Angular app in Guest mode.', articles: ['1.1'], - tags: ['identityserver', 'logout', 'oauth2', 'oidc'], + tags: ['identityserver', 'logout', 'oauth2', 'oidc', 'session'], useFor: 'Illustrate the IdentityServer-managed logout redirect step.', }); }); @@ -312,26 +387,28 @@ test.describe('Series 1 — Authentication', () => { test.describe('Series 2 — .NET API', () => { const swaggerBase = APP_URLS.api.replace('/api/v1', '') + '/swagger'; - test('swagger — employees endpoints expanded', async ({ page }) => { + test('swagger — employees endpoints', async ({ page }) => { await page.goto(swaggerBase, { waitUntil: 'networkidle', timeout: 15000 }); await settle(page, 2000); - const employeesSection = page.locator('.opblock-tag-section, .opblock-tag').filter({ hasText: /employee/i }).first(); - if (await employeesSection.count() > 0) { - await employeesSection.click(); + const section = page.locator('.opblock-tag-section, .opblock-tag').filter({ hasText: /employee/i }).first(); + if (await section.count() > 0) { + await section.click(); await page.waitForTimeout(1000); } await shot(page, 'series-2-dotnet-api', 'swagger-employees-endpoints.png', { description: - 'Swagger UI with the Employees controller expanded — shows GET, POST, PUT, DELETE endpoints with versioning and JWT lock icons indicating auth-protected routes.', + 'Swagger UI Employees controller expanded — GET, POST, PUT, DELETE endpoints with versioning and JWT lock icons.', + narration: + 'The Employees controller exposes a full set of versioned REST endpoints. The lock icons indicate which routes require a Bearer token — in this case all of them except the read endpoint for anonymous access.', articles: ['2.1', '2.3', '2.4'], - tags: ['swagger', 'api', 'employees', 'crud', 'versioning', 'jwt'], - useFor: 'Document the employee CRUD API surface; show JWT auth requirement on protected endpoints.', + tags: ['swagger', 'employees', 'crud', 'versioning', 'jwt', 'dotnet'], + useFor: 'Document the employee CRUD API surface and JWT auth requirement.', }); }); - test('swagger — AI endpoints expanded', async ({ page }) => { + test('swagger — AI endpoints', async ({ page }) => { await page.goto(swaggerBase, { waitUntil: 'networkidle', timeout: 15000 }); await settle(page, 2000); @@ -342,22 +419,24 @@ test.describe('Series 2 — .NET API', () => { await shot(page, 'series-2-dotnet-api', 'swagger-ai-endpoints.png', { description: - 'Swagger UI with the AI controller expanded — shows POST /ai/chat, POST /ai/hr-insight, and POST /ai/nl-employee-search endpoints.', + 'Swagger UI AI controller expanded — POST /ai/chat, POST /ai/hr-insight, POST /ai/nl-employee-search.', + narration: + 'Series 6 adds an AI controller with three endpoints. The chat endpoint accepts any question. The H R insight endpoint grounds the answer in live workforce data. And the natural language search endpoint parses plain English into structured employee filter parameters.', articles: ['6.1', '6.2', '6.5'], - tags: ['swagger', 'api', 'ai', 'chat', 'hr-insight', 'ollama'], - useFor: 'Show the AI endpoint surface available after enabling AiEnabled feature flag.', + tags: ['swagger', 'ai', 'chat', 'hr-insight', 'nl-search', 'ollama'], + useFor: 'Show the full AI endpoint surface after enabling the AiEnabled feature flag.', }); } }); - test('swagger — ai/chat try-it-out expanded', async ({ page }) => { + test('swagger — ai/chat endpoint expanded', async ({ page }) => { await page.goto(swaggerBase, { waitUntil: 'networkidle', timeout: 15000 }); await settle(page, 2000); const aiSection = page.locator('.opblock-tag-section, .opblock-tag').filter({ hasText: /^ai$/i }).first(); if (await aiSection.count() > 0) { await aiSection.click(); - await page.waitForTimeout(1000); + await page.waitForTimeout(800); const chatEndpoint = page.locator('.opblock-post').filter({ hasText: /\/ai\/chat/i }).first(); if (await chatEndpoint.count() > 0) { @@ -366,10 +445,12 @@ test.describe('Series 2 — .NET API', () => { await shot(page, 'series-2-dotnet-api', 'swagger-ai-chat-endpoint.png', { description: - 'Swagger UI showing the POST /api/v1/ai/chat endpoint expanded with its request body schema (message, systemPrompt fields).', + 'Swagger UI POST /api/v1/ai/chat endpoint expanded — shows request body schema with message and systemPrompt fields.', + narration: + 'The chat endpoint accepts two fields: message is the question to ask, and systemPrompt is an optional instruction that controls the AI\'s persona or constraints. Both are plain strings — no special formatting required.', articles: ['6.1'], - tags: ['swagger', 'ai', 'chat', 'endpoint', 'request-body'], - useFor: 'Illustrate how to test the AI chat endpoint directly from Swagger UI in Article 6.1.', + tags: ['swagger', 'ai', 'chat', 'request-body', 'system-prompt'], + useFor: 'Illustrate how to test the AI chat endpoint from Swagger in Article 6.1.', }); } } @@ -381,17 +462,19 @@ test.describe('Series 2 — .NET API', () => { // --------------------------------------------------------------------------- test.describe('Series 3 — Angular Material', () => { - test('dashboard — metrics cards and charts', async ({ page }) => { + test('dashboard — metrics and charts', async ({ page }) => { await loginAsRole(page, 'manager'); await page.goto('/dashboard'); await settle(page, 3000); await shot(page, 'series-3-angular-material', 'dashboard-metrics-charts.png', { description: - 'TalentManagement dashboard showing KPI metric cards (total employees, departments, new hires) and Chart.js bar/doughnut charts for department and gender distribution.', + 'Dashboard showing KPI metric cards (total employees, departments, new hires) and Chart.js bar/doughnut charts for department and gender distribution.', + narration: + 'The dashboard displays live workforce metrics as Material Design cards and Chart.js visualisations. The data comes from a single API call to the dashboard metrics endpoint, which aggregates counts across employees, departments, and positions.', articles: ['3.1', '3.4', '6.4'], - tags: ['dashboard', 'charts', 'metrics', 'angular-material', 'chartjs'], - useFor: 'Hero image for dashboard and Chart.js articles; also the base screenshot for AI insights overlay comparison in Series 6.', + tags: ['dashboard', 'charts', 'metrics', 'angular-material', 'chartjs', 'kpi'], + useFor: 'Hero image for dashboard articles; base screenshot for AI insights overlay comparison in Series 6.', }, { fullPage: true }); }); @@ -402,9 +485,11 @@ test.describe('Series 3 — Angular Material', () => { await shot(page, 'series-3-angular-material', 'employee-list-table.png', { description: - 'Angular Material data table listing employees with sortable columns (name, department, position, hire date), pagination controls, and a search/filter bar.', + 'Angular Material data table listing employees — sortable columns, pagination controls, and a search/filter bar.', + narration: + 'The employee list uses an Angular Material data table with server-side sorting and pagination. The search bar filters results by name or department without reloading the page.', articles: ['3.1', '6.5'], - tags: ['employee-list', 'data-table', 'angular-material', 'pagination', 'sorting'], + tags: ['employee-list', 'data-table', 'pagination', 'sorting', 'angular-material'], useFor: 'Illustrate the Material Design data table component and the employee list feature.', }); }); @@ -417,12 +502,14 @@ test.describe('Series 3 — Angular Material', () => { const createBtn = page.locator('button').filter({ hasText: /create|add/i }).first(); if (await createBtn.count() > 0) { await createBtn.click(); - await page.waitForSelector('mat-dialog-container, .mat-dialog-container, form', { timeout: 5000 }); + await page.waitForSelector('mat-dialog-container, form', { timeout: 5000 }); await page.waitForTimeout(800); await shot(page, 'series-3-angular-material', 'employee-create-form.png', { description: - 'Angular Material dialog showing the Create Employee reactive form with fields for name, email, department, position, hire date, and gender — with inline validation.', + 'Material dialog showing Create Employee reactive form — fields for name, email, department, position, hire date, gender — with inline validation.', + narration: + 'Clicking Create opens a Material dialog with a reactive form. All fields use Angular Material form controls with built-in validation. Errors appear inline as you type, following the Material Design specification.', articles: ['3.2', '3.3'], tags: ['employee-form', 'reactive-forms', 'mat-dialog', 'validation', 'angular-material'], useFor: 'Illustrate the reactive form inside a Material dialog for the forms and dialogs articles.', @@ -437,9 +524,11 @@ test.describe('Series 3 — Angular Material', () => { await shot(page, 'series-3-angular-material', 'department-list-table.png', { description: - 'Department management page showing a Material data table with department names and action buttons (edit, delete) accessible to Manager and HRAdmin roles.', + 'Department management page — Material data table with department names and edit/delete action buttons.', + narration: + 'The department list follows the same Material table pattern as the employee list. Managers can create, edit, and delete departments. The table refreshes automatically after each operation.', articles: ['3.1'], - tags: ['department-list', 'data-table', 'angular-material', 'crud'], + tags: ['department-list', 'data-table', 'crud', 'angular-material'], useFor: 'Illustrate the department management feature alongside the employee list.', }); }); @@ -451,10 +540,12 @@ test.describe('Series 3 — Angular Material', () => { await shot(page, 'series-3-angular-material', 'position-list-table.png', { description: - 'Position management page visible only to the HRAdmin role — shows a Material table of job positions with title, department, and salary range columns.', + 'Position management page — HRAdmin-only table of job positions with title, department, and salary range columns.', + narration: + 'Positions are visible only to the HRAdmin role. The ngx-permissions directive hides this page from Managers and Employees entirely — both in the sidebar and via route guard.', articles: ['1.4', '3.1'], - tags: ['position-list', 'hradmin', 'role-based-ui', 'data-table'], - useFor: 'Demonstrate HRAdmin-only feature access; useful for role-based UI articles.', + tags: ['position-list', 'hradmin', 'role-based-ui', 'data-table', 'ngx-permissions'], + useFor: 'Demonstrate HRAdmin-only feature access for role-based UI articles.', }); }); @@ -465,7 +556,9 @@ test.describe('Series 3 — Angular Material', () => { await shot(page, 'series-3-angular-material', 'salary-ranges-table.png', { description: - 'Salary Range management page restricted to HRAdmin — shows a table with range label, minimum, and maximum salary columns.', + 'Salary Range management page restricted to HRAdmin — table with range label, minimum and maximum salary columns.', + narration: + 'Salary ranges are an HRAdmin-only feature. They define the pay bands that Positions reference, creating a hierarchy from Salary Range down to Position down to Employee.', articles: ['1.4', '3.1'], tags: ['salary-ranges', 'hradmin', 'role-based-ui', 'data-table'], useFor: 'Show the HRAdmin-exclusive salary range management feature.', @@ -484,39 +577,43 @@ test.describe('Series 6 — AI Features', () => { await settle(page, 2000); const aiCard = page.locator('.ai-insights-card, mat-card:has(mat-icon:has-text("smart_toy"))').first(); - const hasAiCard = await aiCard.count() > 0; - if (hasAiCard) { - // Wait for AI text to arrive from Ollama (up to 30s) + 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: - 'TalentManagement dashboard with the AI Insights mat-card at the top — shows an LLM-generated plain-English executive summary of live workforce metrics produced by Ollama.', + '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', 'angular-material'], - useFor: 'Hero image for Article 6.4; shows the AI card in context above the metric cards.', + 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.', }); 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, "AI Workforce Insights" title, and the generated executive summary text.', + '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: 'Use as an inline image in Article 6.4 step-by-step walkthrough to show the finished card component.', + 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 the AI insights card — the state when aiEnabled is false in environment.ts. Original dashboard is completely unaffected by the AI feature flag.', + '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 revealing the after state (AI enabled) in Article 6.4.', + useFor: 'Show the before state (AI disabled) before the after state (AI enabled) in Article 6.4.', }); } }); @@ -528,14 +625,16 @@ test.describe('Series 6 — AI Features', () => { await shot(page, 'series-6-ai-app-features', 'ai-chat-page-full.png', { description: - 'Full AI Assistant page (/ai-chat) — either shows the two-tab chat UI (when aiEnabled: true) or an info banner explaining how to enable AI features (when false).', + 'Full AI Assistant page at /ai-chat — two-tab chat UI 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.', articles: ['6.3'], - tags: ['ai-chat', 'angular-material', 'mat-tab-group', 'feature-flag'], + tags: ['ai-chat', 'mat-tab-group', 'feature-flag', 'angular-material'], useFor: 'Hero image for Article 6.3 showing the complete chat page layout.', }, { fullPage: true }); }); - test('AI chat — Tab 1 with reply', async ({ page }) => { + test('AI chat — Tab 1 general chat with reply', async ({ page }) => { await loginAsRole(page, 'manager'); await page.goto('/ai-chat'); await settle(page, 1500); @@ -544,7 +643,9 @@ test.describe('Series 6 — AI Features', () => { if (await tabGroup.count() === 0) { await shot(page, 'series-6-ai-app-features', 'ai-chat-disabled-banner.png', { description: - 'AI Assistant page showing the info banner when aiEnabled is false — explains that Ollama must be running and AiEnabled set to true.', + '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.', @@ -554,10 +655,12 @@ test.describe('Series 6 — AI Features', () => { await shot(page, 'series-6-ai-app-features', 'ai-chat-tab1-general-empty.png', { description: - 'AI Assistant page, Tab 1 (General Chat) — empty state showing the text input and Send button before any messages are sent.', + 'AI Assistant Tab 1 (General Chat) — empty state before any messages are sent.', + 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.', articles: ['6.3'], - tags: ['ai-chat', 'general-chat', 'tab1', 'empty-state', 'angular-material'], - useFor: 'Show the initial empty chat state at the beginning of Article 6.3.', + tags: ['ai-chat', 'general-chat', 'tab1', 'empty-state'], + useFor: 'Show the initial empty chat state at the start of Article 6.3.', }); const input = page.locator('mat-tab-body textarea, mat-tab-body input[type="text"]').first(); @@ -565,18 +668,18 @@ test.describe('Series 6 — AI Features', () => { 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, .chat-message', - { timeout: 30000 } - ).catch(() => {}); + await page.waitForSelector('[class*="assistant"], .message-assistant', { timeout: 30000 }) + .catch(() => {}); await page.waitForTimeout(2000); await shot(page, 'series-6-ai-app-features', 'ai-chat-tab1-with-reply.png', { description: - 'AI Assistant Tab 1 (General Chat) showing a user question about OAuth 2.0 vs OIDC and Ollama\'s reply — demonstrates the conversation history layout with user/assistant message bubbles.', + '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.', + 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.', articles: ['6.3'], tags: ['ai-chat', 'general-chat', 'tab1', 'ollama-reply', 'conversation'], - useFor: 'Show the live chat in action in Article 6.3.', + useFor: 'Show the live general chat in action in Article 6.3.', }); } }); @@ -597,13 +700,14 @@ test.describe('Series 6 — AI Features', () => { await shot(page, 'series-6-ai-app-features', 'ai-chat-tab2-hr-insights-empty.png', { description: - 'AI Assistant Tab 2 (HR Insights) in empty state — shows the four pre-filled suggestion buttons and text input before any question is asked.', + 'AI Assistant Tab 2 (HR Insights) — empty state showing four pre-filled suggestion buttons and text 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.', articles: ['6.3'], tags: ['ai-chat', 'hr-insights', 'tab2', 'suggestion-buttons', 'empty-state'], - useFor: 'Show the HR Insights tab layout including the suggestion buttons in Article 6.3.', + useFor: 'Show the HR Insights tab layout and suggestion buttons in Article 6.3.', }); - // Click first suggestion or type a question const suggestionBtn = page.locator('mat-tab-body button[mat-stroked-button], mat-tab-body button[mat-flat-button]').first(); if (await suggestionBtn.count() > 0) { await suggestionBtn.click(); @@ -615,16 +719,17 @@ test.describe('Series 6 — AI Features', () => { } } - // Wait for Ollama (up to 30s) 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', { description: - 'AI Assistant Tab 2 (HR Insights) showing a data-grounded answer from Ollama — the reply references actual department headcounts from the live database rather than hallucinated figures. Execution time shown below the answer.', + 'HR Insights Tab 2 showing a data-grounded Ollama answer — references actual department headcounts from the live database. 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.', articles: ['6.2', '6.3'], - tags: ['ai-chat', 'hr-insights', 'tab2', 'rag', 'grounded-answer', 'execution-time'], - useFor: 'Key proof-of-concept image for Article 6.2 and 6.3 — shows that the AI answer is grounded in real workforce data, not hallucinated.', + 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.', }); }); }); From 321ddadb33f725a4fffa8cdf4f91b74b7bf58083 Mon Sep 17 00:00:00 2001 From: Fuji Nguyen Date: Mon, 20 Apr 2026 22:01:10 -0400 Subject: [PATCH 04/10] Update @playwright/test to 1.59.1 --- package-lock.json | 24 ++++++++++++------------ package.json | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 777b2ec..531aeb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,18 +9,18 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@playwright/test": "^1.58.2", + "@playwright/test": "1.59.1", "@types/node": "^25.2.2" } }, "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.58.2" + "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" @@ -55,13 +55,13 @@ } }, "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.2" + "playwright-core": "1.59.1" }, "bin": { "playwright": "cli.js" @@ -74,9 +74,9 @@ } }, "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 91fdae1..55d310e 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "license": "ISC", "description": "", "devDependencies": { - "@playwright/test": "^1.58.2", + "@playwright/test": "1.59.1", "@types/node": "^25.2.2" } } From 56136960f2efff04fbd86c3eb5b645b9f5a49292 Mon Sep 17 00:00:00 2001 From: Fuji Nguyen Date: Mon, 20 Apr 2026 22:03:05 -0400 Subject: [PATCH 05/10] Fix speak.ps1 encoding issue caused by non-ASCII characters --- scripts/speak.ps1 | 105 +++++++++++----------------------------------- 1 file changed, 25 insertions(+), 80 deletions(-) diff --git a/scripts/speak.ps1 b/scripts/speak.ps1 index cf4e1d0..b16bc8e 100644 --- a/scripts/speak.ps1 +++ b/scripts/speak.ps1 @@ -1,92 +1,37 @@ -<# -.SYNOPSIS - Generates a WAV audio file from text using Windows built-in TTS. - -.DESCRIPTION - Uses System.Speech.Synthesis.SpeechSynthesizer (built into Windows — no - external API key or installation required) to convert a narration string - into a WAV file. - - Called by the Playwright screenshots spec after each page.screenshot() call - so that every blog screenshot has a matching narration audio file. - -.PARAMETER Text - The narration text to speak. Keep to 1-3 sentences for best pacing. - -.PARAMETER OutputPath - Full path for the output WAV file (e.g. screenshots-output\series-1\dashboard.wav). - Parent directory must already exist. - -.PARAMETER Voice - Optional. Name of an installed Windows TTS voice. - Run: Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).GetInstalledVoices() | Select -ExpandProperty VoiceInfo | Select Name - Defaults to the system default voice. - -.PARAMETER Rate - Speech rate: -10 (slowest) to 10 (fastest). Default: -1 (slightly slower - than default for clearer narration in video). - -.PARAMETER Volume - Volume: 0-100. Default: 100. - -.EXAMPLE - .\speak.ps1 -Text "The dashboard loads immediately after login." -OutputPath "screenshots-output\series-1\dashboard.wav" - -.EXAMPLE - .\speak.ps1 -Text "Here we can see the AI insights card." -OutputPath "out.wav" -Voice "Microsoft David Desktop" -Rate -2 -#> - -[CmdletBinding()] param( - [Parameter(Mandatory = $true)] - [string] $Text, - - [Parameter(Mandatory = $true)] - [string] $OutputPath, - - [string] $Voice = "", - [int] $Rate = -1, - [int] $Volume = 100 + [Parameter(Mandatory=$true)][string]$Text, + [Parameter(Mandatory=$true)][string]$OutputPath, + [string]$Voice = "", + [int]$Rate = -1, + [int]$Volume = 100 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -try { - Add-Type -AssemblyName System.Speech +Add-Type -AssemblyName System.Speech +$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer - $synth = New-Object System.Speech.Synthesis.SpeechSynthesizer - - # Select voice if specified; otherwise use system default - if ($Voice -ne "") { - try { - $synth.SelectVoice($Voice) - } catch { - Write-Warning "Voice '$Voice' not found — using system default. Available voices:" - $synth.GetInstalledVoices() | ForEach-Object { - Write-Warning (" " + $_.VoiceInfo.Name) - } - } +if ($Voice -ne "") { + try { + $synth.SelectVoice($Voice) + } catch { + Write-Warning "Voice '$Voice' not found, using system default." } +} - $synth.Rate = [Math]::Max(-10, [Math]::Min(10, $Rate)) - $synth.Volume = [Math]::Max(0, [Math]::Min(100, $Volume)) - - # Ensure output directory exists - $dir = Split-Path -Parent $OutputPath - if ($dir -and -not (Test-Path $dir)) { - New-Item -ItemType Directory -Path $dir -Force | Out-Null - } +$synth.Rate = [Math]::Max(-10, [Math]::Min(10, $Rate)) +$synth.Volume = [Math]::Max(0, [Math]::Min(100, $Volume)) - $synth.SetOutputToWaveFile($OutputPath) - $synth.Speak($Text) - $synth.SetOutputToDefaultAudioDevice() # reset so the object is reusable - $synth.Dispose() +$dir = Split-Path -Parent $OutputPath +if ($dir -and -not (Test-Path $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null +} - Write-Host "Audio saved: $OutputPath" - exit 0 +$synth.SetOutputToWaveFile($OutputPath) +$synth.Speak($Text) +$synth.SetOutputToDefaultAudioDevice() +$synth.Dispose() -} catch { - Write-Error "speak.ps1 failed: $_" - exit 1 -} +Write-Host "Audio saved: $OutputPath" +exit 0 From f5201aaf111849529c901f331c0a746ec60d499e Mon Sep 17 00:00:00 2001 From: Fuji Nguyen Date: Mon, 20 Apr 2026 22:05:34 -0400 Subject: [PATCH 06/10] Increase timeout on HR insights screenshot test to 90s for Ollama inference --- tests/screenshots/blog-screenshots.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/screenshots/blog-screenshots.spec.ts b/tests/screenshots/blog-screenshots.spec.ts index c94eec6..91277c6 100644 --- a/tests/screenshots/blog-screenshots.spec.ts +++ b/tests/screenshots/blog-screenshots.spec.ts @@ -685,6 +685,7 @@ test.describe('Series 6 — AI Features', () => { }); test('AI chat — Tab 2 HR insights with answer', async ({ page }) => { + test.setTimeout(90000); // Ollama inference can take 30-60s; allow extra buffer await loginAsRole(page, 'manager'); await page.goto('/ai-chat'); await settle(page, 1500); From 4905777c6220afaa1adce1fbe7d81726fb98ae65 Mon Sep 17 00:00:00 2001 From: Fuji Nguyen Date: Mon, 20 Apr 2026 22:08:40 -0400 Subject: [PATCH 07/10] Rename package from playwright to angularnettutorial-playwright to avoid npm conflict --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 55d310e..b553b87 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "playwright", + "name": "angularnettutorial-playwright", "version": "1.0.0", "main": "index.js", "scripts": {}, From 9c08b7232f07a3d2a918e43e0423aadb35d7a1f7 Mon Sep 17 00:00:00 2001 From: Fuji Nguyen Date: Mon, 20 Apr 2026 22:44:47 -0400 Subject: [PATCH 08/10] Play narration through speakers in addition to saving WAV file --- scripts/speak.ps1 | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/speak.ps1 b/scripts/speak.ps1 index b16bc8e..e540a1b 100644 --- a/scripts/speak.ps1 +++ b/scripts/speak.ps1 @@ -28,9 +28,14 @@ if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } +# Save to WAV file $synth.SetOutputToWaveFile($OutputPath) $synth.Speak($Text) + +# Play through speakers so narration is heard live during the test run $synth.SetOutputToDefaultAudioDevice() +$synth.Speak($Text) + $synth.Dispose() Write-Host "Audio saved: $OutputPath" From 2d7f0819fa0cccb8aaae5ed859aa0272c502a0ff Mon Sep 17 00:00:00 2001 From: Fuji Nguyen Date: Tue, 21 Apr 2026 06:46:21 -0400 Subject: [PATCH 09/10] Screenshots script --- screenshot-catalog.json | 126 +++++++++++++++++++++++++++++++++++ scripts/build-video.ps1 | 141 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 screenshot-catalog.json create mode 100644 scripts/build-video.ps1 diff --git a/screenshot-catalog.json b/screenshot-catalog.json new file mode 100644 index 0000000..003d502 --- /dev/null +++ b/screenshot-catalog.json @@ -0,0 +1,126 @@ +{ + "generated": "2026-04-21T10:22:44.270Z", + "screenshots": [ + { + "path": "screenshots-output/series-3-angular-material/employee-create-form.png", + "audioPath": "screenshots-output/series-3-angular-material/employee-create-form.wav", + "series": "series-3-angular-material", + "filename": "employee-create-form.png", + "capturedAt": "2026-04-21T10:23:08.161Z", + "description": "Material dialog showing Create Employee reactive form — fields for name, email, department, position, hire date, gender — with inline validation.", + "narration": "Clicking Create opens a Material dialog with a reactive form. All fields use Angular Material form controls with built-in validation. Errors appear inline as you type, following the Material Design specification.", + "articles": [ + "3.2", + "3.3" + ], + "tags": [ + "employee-form", + "reactive-forms", + "mat-dialog", + "validation", + "angular-material" + ], + "useFor": "Illustrate the reactive form inside a Material dialog for the forms and dialogs articles." + }, + { + "path": "screenshots-output/series-3-angular-material/department-list-table.png", + "audioPath": "screenshots-output/series-3-angular-material/department-list-table.wav", + "series": "series-3-angular-material", + "filename": "department-list-table.png", + "capturedAt": "2026-04-21T10:23:29.738Z", + "description": "Department management page — Material data table with department names and edit/delete action buttons.", + "narration": "The department list follows the same Material table pattern as the employee list. Managers can create, edit, and delete departments. The table refreshes automatically after each operation.", + "articles": [ + "3.1" + ], + "tags": [ + "department-list", + "data-table", + "crud", + "angular-material" + ], + "useFor": "Illustrate the department management feature alongside the employee list." + }, + { + "path": "screenshots-output/series-3-angular-material/position-list-table.png", + "audioPath": "screenshots-output/series-3-angular-material/position-list-table.wav", + "series": "series-3-angular-material", + "filename": "position-list-table.png", + "capturedAt": "2026-04-21T10:23:49.468Z", + "description": "Position management page — HRAdmin-only table of job positions with title, department, and salary range columns.", + "narration": "Positions are visible only to the HRAdmin role. The ngx-permissions directive hides this page from Managers and Employees entirely — both in the sidebar and via route guard.", + "articles": [ + "1.4", + "3.1" + ], + "tags": [ + "position-list", + "hradmin", + "role-based-ui", + "data-table", + "ngx-permissions" + ], + "useFor": "Demonstrate HRAdmin-only feature access for role-based UI articles." + }, + { + "path": "screenshots-output/series-3-angular-material/salary-ranges-table.png", + "audioPath": "screenshots-output/series-3-angular-material/salary-ranges-table.wav", + "series": "series-3-angular-material", + "filename": "salary-ranges-table.png", + "capturedAt": "2026-04-21T10:24:08.874Z", + "description": "Salary Range management page restricted to HRAdmin — table with range label, minimum and maximum salary columns.", + "narration": "Salary ranges are an HRAdmin-only feature. They define the pay bands that Positions reference, creating a hierarchy from Salary Range down to Position down to Employee.", + "articles": [ + "1.4", + "3.1" + ], + "tags": [ + "salary-ranges", + "hradmin", + "role-based-ui", + "data-table" + ], + "useFor": "Show the HRAdmin-exclusive salary range management feature." + }, + { + "path": "screenshots-output/series-6-ai-app-features/dashboard-ai-insights-card.png", + "audioPath": "screenshots-output/series-6-ai-app-features/dashboard-ai-insights-card.wav", + "series": "series-6-ai-app-features", + "filename": "dashboard-ai-insights-card.png", + "capturedAt": "2026-04-21T10:24:31.787Z", + "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." + }, + { + "path": "screenshots-output/series-6-ai-app-features/dashboard-ai-insights-card-closeup.png", + "audioPath": "screenshots-output/series-6-ai-app-features/dashboard-ai-insights-card-closeup.wav", + "series": "series-6-ai-app-features", + "filename": "dashboard-ai-insights-card-closeup.png", + "capturedAt": "2026-04-21T10:24:47.463Z", + "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." + } + ] +} \ No newline at end of file diff --git a/scripts/build-video.ps1 b/scripts/build-video.ps1 new file mode 100644 index 0000000..acb6e9e --- /dev/null +++ b/scripts/build-video.ps1 @@ -0,0 +1,141 @@ +param( + [string]$InputDir = "", + [string]$OutputFile = "blog-narrated-video.mp4", + [string]$Series = "", + [switch]$AllSeries, + [int]$PadSeconds = 1 +) + +# build-video.ps1 +# +# Stitches PNG screenshots + WAV narrations into a narrated MP4 slideshow. +# Requires FFmpeg on PATH: https://ffmpeg.org/download.html +# +# Usage: +# # Single series +# .\build-video.ps1 -Series "series-1-authentication" -OutputFile "auth.mp4" +# +# # All series into one video +# .\build-video.ps1 -AllSeries -OutputFile "blog-full-series.mp4" +# +# # Custom input folder +# .\build-video.ps1 -InputDir "screenshots-output\series-6-ai-app-features" -OutputFile "ai.mp4" + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Verify FFmpeg is available +if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) { + Write-Error "FFmpeg not found on PATH. Download from https://ffmpeg.org/download.html and add to PATH." + exit 1 +} + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$SubmoduleRoot = Split-Path -Parent $ScriptDir +$ScreenshotsRoot = Join-Path $SubmoduleRoot "screenshots-output" + +# Resolve which folders to process +$folders = @() +if ($InputDir -ne "") { + $folders = @($InputDir) +} elseif ($AllSeries) { + $folders = Get-ChildItem -Path $ScreenshotsRoot -Directory | Sort-Object Name | Select-Object -ExpandProperty FullName +} elseif ($Series -ne "") { + $folders = @(Join-Path $ScreenshotsRoot $Series) +} else { + Write-Error "Specify -Series , -AllSeries, or -InputDir ." + exit 1 +} + +# Collect PNG+WAV pairs across all resolved folders +$pairs = @() +foreach ($folder in $folders) { + if (-not (Test-Path $folder)) { + Write-Warning "Folder not found, skipping: $folder" + continue + } + $pngs = Get-ChildItem -Path $folder -Filter "*.png" | Sort-Object Name + foreach ($png in $pngs) { + $wav = [System.IO.Path]::ChangeExtension($png.FullName, ".wav") + if (Test-Path $wav) { + $pairs += [PSCustomObject]@{ PNG = $png.FullName; WAV = $wav } + } else { + Write-Warning "No WAV for $($png.Name) — skipping (run the screenshot tests first)." + } + } +} + +if ($pairs.Count -eq 0) { + Write-Error "No PNG+WAV pairs found. Run: npx playwright test --project=screenshots" + exit 1 +} + +Write-Host "Building video from $($pairs.Count) slides..." + +# Temp folder for per-slide clips +$TempDir = Join-Path $env:TEMP "pw-slides-$(Get-Date -Format 'yyyyMMddHHmmss')" +New-Item -ItemType Directory -Path $TempDir | Out-Null + +$clipList = Join-Path $TempDir "clips.txt" +$clips = @() + +for ($i = 0; $i -lt $pairs.Count; $i++) { + $pair = $pairs[$i] + $clipOut = Join-Path $TempDir "clip_$($i.ToString('D4')).mp4" + + # Get WAV duration so the image is shown for exactly that long + padding + $durationJson = & ffprobe -v quiet -print_format json -show_streams $pair.WAV 2>&1 | ConvertFrom-Json + $wavDuration = [double]($durationJson.streams[0].duration) + $slideDuration = [Math]::Round($wavDuration + $PadSeconds, 2) + + Write-Host " Slide $($i+1)/$($pairs.Count): $([System.IO.Path]::GetFileNameWithoutExtension($pair.PNG)) ($($slideDuration)s)" + + # Build one clip: PNG scaled to 1920x1080 (letterboxed) + WAV + pad silence + & ffmpeg -y ` + -loop 1 -t $slideDuration -i $pair.PNG ` + -i $pair.WAV ` + -vf "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:black" ` + -c:v libx264 -preset fast -crf 22 ` + -c:a aac -b:a 128k ` + -shortest ` + -pix_fmt yuv420p ` + $clipOut 2>&1 | Out-Null + + if (-not (Test-Path $clipOut)) { + Write-Warning "FFmpeg failed on slide $($i+1), skipping." + continue + } + + $clips += $clipOut + Add-Content -Path $clipList -Value "file '$clipOut'" +} + +if ($clips.Count -eq 0) { + Write-Error "No clips were created. Check FFmpeg output above." + exit 1 +} + +# Resolve output path relative to submodule root if not absolute +if (-not [System.IO.Path]::IsPathRooted($OutputFile)) { + $OutputFile = Join-Path $SubmoduleRoot $OutputFile +} + +Write-Host "" +Write-Host "Concatenating $($clips.Count) clips into: $OutputFile" + +& ffmpeg -y -f concat -safe 0 -i $clipList -c copy $OutputFile 2>&1 | Out-Null + +# Cleanup temp clips +Remove-Item -Recurse -Force $TempDir + +if (Test-Path $OutputFile) { + $size = [Math]::Round((Get-Item $OutputFile).Length / 1MB, 1) + Write-Host "" + Write-Host "Done: $OutputFile ($size MB)" + Write-Host "" + Write-Host "Play with: Start-Process '$OutputFile'" + Write-Host "Or open in any media player." +} else { + Write-Error "Output file was not created. Check FFmpeg errors above." + exit 1 +} From 27e726f5c6fa43dec0031ac0037ae0fb187eba8b Mon Sep 17 00:00:00 2001 From: Fuji Nguyen Date: Tue, 21 Apr 2026 13:17:26 -0400 Subject: [PATCH 10/10] Phase 3: Add AI submenu page objects, test suites, and update screenshot spec --- page-objects/ai-assistant.page.ts | 59 +++++ page-objects/ai-hr-insight.page.ts | 64 ++++++ page-objects/ai-nl-search.page.ts | 70 ++++++ page-objects/ai-vector-search.page.ts | 70 ++++++ tests/ai/ai-assistant.spec.ts | 150 ++++++++++++ tests/ai/ai-hr-insight.spec.ts | 128 +++++++++++ tests/ai/ai-navigation.spec.ts | 111 +++++++++ tests/ai/ai-nl-search.spec.ts | 154 +++++++++++++ tests/ai/ai-vector-search.spec.ts | 178 ++++++++++++++ tests/screenshots/blog-screenshots.spec.ts | 255 ++++++++++++--------- 10 files changed, 1135 insertions(+), 104 deletions(-) create mode 100644 page-objects/ai-assistant.page.ts create mode 100644 page-objects/ai-hr-insight.page.ts create mode 100644 page-objects/ai-nl-search.page.ts create mode 100644 page-objects/ai-vector-search.page.ts create mode 100644 tests/ai/ai-assistant.spec.ts create mode 100644 tests/ai/ai-hr-insight.spec.ts create mode 100644 tests/ai/ai-navigation.spec.ts create mode 100644 tests/ai/ai-nl-search.spec.ts create mode 100644 tests/ai/ai-vector-search.spec.ts 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.', + }); + } + }); });