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/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..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": {}, @@ -8,7 +8,7 @@ "license": "ISC", "description": "", "devDependencies": { - "@playwright/test": "^1.58.2", + "@playwright/test": "1.59.1", "@types/node": "^25.2.2" } } 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/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/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 +} diff --git a/scripts/speak.ps1 b/scripts/speak.ps1 new file mode 100644 index 0000000..e540a1b --- /dev/null +++ b/scripts/speak.ps1 @@ -0,0 +1,42 @@ +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' + +Add-Type -AssemblyName System.Speech +$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer + +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)) + +$dir = Split-Path -Parent $OutputPath +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" +exit 0 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 new file mode 100644 index 0000000..ba17fa2 --- /dev/null +++ b/tests/screenshots/blog-screenshots.spec.ts @@ -0,0 +1,783 @@ +/** + * Blog Screenshots + * + * Captures key UI states from the TalentManagement app for use in blog posts + * 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/series-x/filename.png ← screenshot + * screenshots-output/series-x/filename.wav ← narration audio (Windows only) + * screenshot-catalog.json ← machine-readable index + * + * Prerequisites: + * - Angular: http://localhost:4200 + * - .NET API: https://localhost:44378 + * - IdentityServer: https://localhost:44310 + * - 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'; + +// --------------------------------------------------------------------------- +// Catalog types +// --------------------------------------------------------------------------- + +interface ScreenshotMeta { + /** 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"]) */ + 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; // 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 { + generated: string; + screenshots: CatalogEntry[]; +} + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +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 +// --------------------------------------------------------------------------- + +let catalog: Catalog = { generated: new Date().toISOString(), screenshots: [] }; + +function saveCatalog(): void { + fs.writeFileSync(CATALOG_PATH, JSON.stringify(catalog, null, 2), 'utf-8'); +} + +// --------------------------------------------------------------------------- +// 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( + page: Page, + series: string, + filename: string, + meta: ScreenshotMeta, + options: { + fullPage?: boolean; + clip?: { x: number; y: number; width: number; height: number }; + } = {} +): Promise { + 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 }); + + // 1. Take screenshot + await page.screenshot({ + path: pngPath, + fullPage: options.fullPage ?? false, + clip: options.clip, + }); + + // 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: relativePng, + audioPath: relativeWav, + series, + filename, + capturedAt: new Date().toISOString(), + ...meta, + }); + saveCatalog(); +} + +/** Wait for 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); + + await shot(page, 'series-0-architecture', 'anonymous-home.png', { + description: + '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; shows the app before login.', + }); + + 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', { + description: + '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 sidebar structure before discussing role-based menu visibility.', + }, { clip: { x: box.x, y: box.y, width: box.width, height: box.height } }); + } + }); + + 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', { + description: + '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.', + }); + }); +}); + +// --------------------------------------------------------------------------- +// Series 1 — Authentication +// --------------------------------------------------------------------------- + +test.describe('Series 1 — Authentication', () => { + test('IdentityServer login form', 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(500); + + const loginOption = page.locator( + 'button:has-text("Login"), a:has-text("Login"), [role="menuitem"]:has-text("Login")' + ).first(); + await loginOption.click(); + + await page.waitForSelector('input[name="Username"]', { timeout: 15000 }); + await settle(page, 1000); + + await shot(page, 'series-1-authentication', 'identityserver-login-form.png', { + description: + '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', 'pkce'], + useFor: 'Illustrate the OIDC redirect step in the OAuth 2.0 PKCE flow.', + }); + }); + + 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', { + description: + '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.', + }); + }); + + 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', { + description: + '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.', + }); + }); + + 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', { + description: + '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 and Logout.', + }); + }); + + 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', { + description: + '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.', + }, { clip: { x: box.x, y: box.y, width: box.width, height: box.height } }); + } + }); + + 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', { + description: + '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: 'Pair with the HRAdmin sidebar for a before/after role comparison.', + }, { 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); + + 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(); + 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', 'session'], + useFor: 'Illustrate the IdentityServer-managed logout redirect step.', + }); + }); +}); + +// --------------------------------------------------------------------------- +// Series 2 — .NET API (Swagger) +// --------------------------------------------------------------------------- + +test.describe('Series 2 — .NET API', () => { + const swaggerBase = APP_URLS.api.replace('/api/v1', '') + '/swagger'; + + test('swagger — employees endpoints', async ({ page }) => { + await page.goto(swaggerBase, { waitUntil: 'networkidle', timeout: 15000 }); + await settle(page, 2000); + + 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 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', 'employees', 'crud', 'versioning', 'jwt', 'dotnet'], + useFor: 'Document the employee CRUD API surface and JWT auth requirement.', + }); + }); + + test('swagger — AI endpoints', 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 shot(page, 'series-2-dotnet-api', 'swagger-ai-endpoints.png', { + description: + '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', 'ai', 'chat', 'hr-insight', 'nl-search', 'ollama'], + useFor: 'Show the full AI endpoint surface after enabling the AiEnabled feature flag.', + }); + } + }); + + 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(800); + + 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 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', 'request-body', 'system-prompt'], + useFor: 'Illustrate how to test the AI chat endpoint from Swagger in Article 6.1.', + }); + } + } + }); +}); + +// --------------------------------------------------------------------------- +// Series 3 — Angular Material UI +// --------------------------------------------------------------------------- + +test.describe('Series 3 — Angular Material', () => { + 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: + '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', 'kpi'], + useFor: 'Hero image for dashboard articles; base screenshot for AI insights overlay comparison in Series 6.', + }, { fullPage: true }); + }); + + 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', { + description: + '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', 'pagination', 'sorting', 'angular-material'], + useFor: 'Illustrate the Material Design data table component and the employee list feature.', + }); + }); + + test('employee create form — dialog', async ({ page }) => { + await loginAsRole(page, 'manager'); + await page.goto('/employees'); + await settle(page, 1500); + + 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, form', { timeout: 5000 }); + await page.waitForTimeout(800); + + await shot(page, 'series-3-angular-material', 'employee-create-form.png', { + 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.', + }); + } + }); + + 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', { + 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.', + }); + }); + + 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', { + 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.', + }); + }); + + 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', { + 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.', + }); + }); +}); + +// --------------------------------------------------------------------------- +// Series 6 — AI Features +// --------------------------------------------------------------------------- + +test.describe('Series 6 — AI Features', () => { + test('AI submenu — sidebar navigation visible', async ({ page }) => { + await loginAsRole(page, 'manager'); + await page.goto('/dashboard'); + await settle(page, 1500); + + 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 Assistant — full page', async ({ page }) => { + await loginAsRole(page, 'manager'); + await page.goto('/ai/assistant'); + await settle(page, 1500); + + await shot(page, 'series-6-ai-app-features', 'ai-assistant-page-full.png', { + description: + '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 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-assistant', 'chat-ui', 'feature-flag', 'angular-material'], + useFor: 'Hero image for Article 6.3 showing the AI Assistant page.', + }, { fullPage: true }); + }); + + test('AI Assistant — disabled banner', async ({ page }) => { + await loginAsRole(page, 'manager'); + await page.goto('/ai/assistant'); + await settle(page, 1500); + + 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-assistant-disabled-banner.png', { + description: + 'AI Assistant page showing info banner when aiEnabled is false — explains environment.ts and appsettings.json settings.', + narration: + '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-assistant', 'disabled-state', 'feature-flag', 'info-banner'], + useFor: 'Show the graceful disabled state in Article 6.3.', + }); + }); + + 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('.message.assistant-message', { timeout: 30000 }).catch(() => {}); + await page.waitForTimeout(1500); + + await shot(page, 'series-6-ai-app-features', 'ai-assistant-with-reply.png', { + description: + 'AI Assistant page showing a user question and Ollama reply — user message bubble on the right, assistant on the left.', + narration: + '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-assistant', 'chat-reply', 'ollama', 'conversation'], + useFor: 'Show the live chat in action in Article 6.3.', + }); + } + }); + + test('AI HR Insight — suggestion buttons and answer', async ({ page }) => { + test.setTimeout(90000); + await loginAsRole(page, 'manager'); + await page.goto('/ai/hr-insight'); + await settle(page, 1500); + + const banner = page.locator('.ai-disabled-banner').first(); + if (await banner.count() > 0) return; + + await shot(page, 'series-6-ai-app-features', 'ai-hr-insight-empty.png', { + description: + 'HR Insight page at /ai/hr-insight — empty state showing four suggestion buttons and the question input.', + narration: + 'The H R Insight page shows suggestion buttons for common workforce questions. Clicking one pre-fills the input field.', + articles: ['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('.suggestion-list button').first(); + if (await suggestionBtn.count() > 0) { + await suggestionBtn.click(); + 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-hr-insight-with-answer.png', { + description: + 'HR Insight page showing a data-grounded Ollama answer — references live department headcounts. Execution time shown below reply.', + narration: + '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-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.', + }); + } + }); +});