/** * Stella Assistant — Black-Box E2E Tests * * QA perspective: tests observable behavior only. No knowledge of internal * implementation, signals, services, or component structure. * * Persona: "Alex" — mid-level developer, new DevOps, first time using Stella Ops. * Tests verify that Alex can discover, use, and benefit from the assistant. * * Coverage: * 1. Mascot visibility & first impression * 2. Tips mode — contextual help per page * 3. Search mode — type-to-search, results, navigation * 4. Chat mode — ask questions, get answers * 5. Mode transitions — tips ↔ search ↔ chat * 6. Dismiss / restore behavior * 7. Persistence across page navigations * 8. Tour engine — guided walkthrough * 9. Admin editor (requires admin role) * 10. Glossary tooltips * 11. Context-driven tips (reacts to page state) * 12. Accessibility (keyboard, ARIA) * 13. Responsive (mobile viewport) */ import { test, expect } from './fixtures/auth.fixture'; const SCREENSHOT_DIR = 'e2e/screenshots/stella-assistant'; async function snap(page: import('@playwright/test').Page, label: string) { await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: false }); } async function go(page: import('@playwright/test').Page, path: string) { await page.goto(path, { waitUntil: 'networkidle', timeout: 30_000 }); await page.waitForLoadState('domcontentloaded'); await page.waitForTimeout(1500); } // ═══════════════════════════════════════════════════════════════════════════ // 1. MASCOT VISIBILITY & FIRST IMPRESSION // ═══════════════════════════════════════════════════════════════════════════ test.describe('1 — Mascot Visibility', () => { test('1.1 mascot appears on dashboard after login', async ({ authenticatedPage: page }) => { await go(page, '/'); // The mascot should be visible in the bottom-right corner const mascot = page.getByRole('button', { name: /stella helper/i }); await expect(mascot).toBeVisible({ timeout: 10_000 }); await snap(page, '01-mascot-visible'); }); test('1.2 mascot has recognizable avatar image', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascotImg = page.locator('img[alt*="Stella"]').last(); await expect(mascotImg).toBeVisible(); // Should have a non-zero rendered size const box = await mascotImg.boundingBox(); expect(box).not.toBeNull(); expect(box!.width).toBeGreaterThan(20); expect(box!.height).toBeGreaterThan(20); }); test('1.3 mascot is present on every authenticated page', async ({ authenticatedPage: page }) => { const routes = [ '/', '/releases', '/security', '/evidence/overview', '/ops/operations', '/setup/integrations', '/ops/policy/vex', ]; for (const route of routes) { await go(page, route); const mascot = page.getByRole('button', { name: /stella helper/i }); await expect(mascot).toBeVisible({ timeout: 5000 }); } }); test('1.4 mascot auto-opens bubble on first visit to a page', async ({ authenticatedPage: page }) => { // Clear any stored state to simulate first visit await page.evaluate(() => { localStorage.removeItem('stellaops.assistant.state'); localStorage.removeItem('stellaops.helper.preferences'); }); await go(page, '/'); await page.waitForTimeout(2000); // Bubble should auto-open with a tip const bubble = page.locator('[role="status"]').first(); await expect(bubble).toBeVisible({ timeout: 5000 }); await snap(page, '01-auto-open-first-visit'); }); }); // ═══════════════════════════════════════════════════════════════════════════ // 2. TIPS MODE — Contextual help per page // ═══════════════════════════════════════════════════════════════════════════ test.describe('2 — Tips Mode', () => { test('2.1 clicking mascot opens tip bubble', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); // Bubble should be visible with tip content const bubble = page.locator('[role="status"]'); await expect(bubble).toBeVisible(); // Should contain a title (bold text about the current page) const tipText = await bubble.innerText(); expect(tipText.length).toBeGreaterThan(20); await snap(page, '02-tip-bubble-open'); }); test('2.2 dashboard tips mention dashboard concepts', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); const tipText = await page.locator('[role="status"]').innerText(); // Dashboard tips should reference dashboard-related concepts const dashboardTerms = ['dashboard', 'overview', 'environment', 'sbom', 'vulnerability', 'feed', 'status']; const hasRelevantTerm = dashboardTerms.some(term => tipText.toLowerCase().includes(term) ); expect(hasRelevantTerm, `Tip should mention a dashboard concept. Got: "${tipText.slice(0, 100)}"`).toBe(true); }); test('2.3 tips change when navigating to a different page', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); const dashboardTip = await page.locator('[role="status"]').innerText(); // Navigate to VEX page await go(page, '/ops/policy/vex'); await mascot.click(); await page.waitForTimeout(500); const vexTip = await page.locator('[role="status"]').innerText(); // Tips should be different expect(vexTip).not.toEqual(dashboardTip); // VEX tip should mention VEX-related concepts const vexTerms = ['vex', 'vulnerability', 'exploitability', 'statement', 'affected']; const hasVexTerm = vexTerms.some(term => vexTip.toLowerCase().includes(term)); expect(hasVexTerm, `VEX tip should mention VEX concepts. Got: "${vexTip.slice(0, 100)}"`).toBe(true); }); test('2.4 tip navigation (prev/next) cycles through tips', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); // Should show tip counter "1 / N" const counter = page.locator('text=/\\d+ \\/ \\d+/'); await expect(counter).toBeVisible(); const counterText = await counter.innerText(); expect(counterText).toMatch(/1 \/ \d+/); // Get first tip title const firstTip = await page.locator('[role="status"]').innerText(); // Click Next const nextBtn = page.getByRole('button', { name: /next tip/i }); await expect(nextBtn).toBeEnabled(); await nextBtn.click(); await page.waitForTimeout(300); // Counter should update const newCounter = await counter.innerText(); expect(newCounter).toMatch(/2 \/ \d+/); // Tip content should change const secondTip = await page.locator('[role="status"]').innerText(); expect(secondTip).not.toEqual(firstTip); // Click Prev const prevBtn = page.getByRole('button', { name: /previous tip/i }); await expect(prevBtn).toBeEnabled(); await prevBtn.click(); await page.waitForTimeout(300); const backToFirst = await counter.innerText(); expect(backToFirst).toMatch(/1 \/ \d+/); }); test('2.5 tips with action buttons navigate to target', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); // Find and click through tips until we find one with an action button const nextBtn = page.getByRole('button', { name: /next tip/i }); for (let i = 0; i < 7; i++) { const actionBtn = page.locator('[role="status"] button').filter({ hasText: /scan|diagnos|check|view|open/i }).first(); if (await actionBtn.isVisible({ timeout: 500 }).catch(() => false)) { const btnText = await actionBtn.innerText(); await actionBtn.click(); await page.waitForTimeout(2000); // Should have navigated away from dashboard const url = page.url(); expect(url).not.toBe('/'); await snap(page, '02-action-button-navigated'); return; } if (await nextBtn.isEnabled()) { await nextBtn.click(); await page.waitForTimeout(300); } } // If no action button found in 7 tips, that's acceptable but notable }); test('2.6 close button dismisses the bubble', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); const closeBtn = page.getByRole('button', { name: /close tip/i }); await expect(closeBtn).toBeVisible(); await closeBtn.click(); await page.waitForTimeout(300); // Bubble should be gone const bubble = page.locator('[role="status"]'); await expect(bubble).not.toBeVisible(); // Mascot should still be visible await expect(mascot).toBeVisible(); }); }); // ═══════════════════════════════════════════════════════════════════════════ // 3. SEARCH MODE — Type to search // ═══════════════════════════════════════════════════════════════════════════ test.describe('3 — Search Mode', () => { test('3.1 search input is visible in the bubble', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); const searchInput = page.getByPlaceholder(/search|ask stella/i); await expect(searchInput).toBeVisible(); }); test('3.2 typing a keyword switches to search mode', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); const input = page.getByPlaceholder(/search|ask stella/i); await input.fill('SBOM vulnerability'); await input.press('Enter'); await page.waitForTimeout(1000); // Should show "Search" in the header or a back-to-tips button const searchHeader = page.locator('text=/Search/i').first(); const backBtn = page.locator('text=/Tips/i').first(); const isSearchMode = await searchHeader.isVisible({ timeout: 2000 }).catch(() => false) || await backBtn.isVisible({ timeout: 2000 }).catch(() => false); expect(isSearchMode).toBe(true); await snap(page, '03-search-mode'); }); test('3.3 back-to-tips button returns to tips mode', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); const input = page.getByPlaceholder(/search|ask stella/i); await input.fill('policy'); await input.press('Enter'); await page.waitForTimeout(1000); // Click back to tips const backBtn = page.locator('button').filter({ hasText: /tips/i }).first(); if (await backBtn.isVisible({ timeout: 2000 }).catch(() => false)) { await backBtn.click(); await page.waitForTimeout(500); // Should show tip content again (not search) const counter = page.locator('text=/\\d+ \\/ \\d+/'); await expect(counter).toBeVisible({ timeout: 3000 }); } }); }); // ═══════════════════════════════════════════════════════════════════════════ // 4. CHAT MODE — Ask Stella questions // ═══════════════════════════════════════════════════════════════════════════ test.describe('4 — Chat Mode', () => { test('4.1 "Ask Stella" button switches to chat mode', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); const askBtn = page.getByRole('button', { name: /ask stella/i }); if (await askBtn.isVisible({ timeout: 2000 }).catch(() => false)) { await askBtn.click(); await page.waitForTimeout(500); // Should show chat header const chatIndicator = page.locator('text=/Ask Stella/i').first(); await expect(chatIndicator).toBeVisible({ timeout: 3000 }); // Input placeholder should change const input = page.getByPlaceholder(/ask stella a question/i); await expect(input).toBeVisible(); await snap(page, '04-chat-mode'); } }); test('4.2 question in input auto-routes to chat mode', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); const input = page.getByPlaceholder(/search|ask stella/i); await input.fill('How do I scan my first container image?'); await input.press('Enter'); await page.waitForTimeout(2000); // Questions should trigger chat mode (not search) const chatIndicator = page.locator('text=/Ask Stella/i').first(); const isChat = await chatIndicator.isVisible({ timeout: 3000 }).catch(() => false); // Even if Advisory AI service is down, the mode should switch expect(isChat).toBe(true); await snap(page, '04-question-to-chat'); }); test('4.3 chat shows error gracefully when AI service unavailable', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); const askBtn = page.getByRole('button', { name: /ask stella/i }); if (await askBtn.isVisible({ timeout: 2000 }).catch(() => false)) { await askBtn.click(); await page.waitForTimeout(500); const input = page.getByPlaceholder(/ask stella/i); await input.fill('What is SBOM?'); await input.press('Enter'); await page.waitForTimeout(3000); // Should show either a response or a graceful error — NOT a blank screen const bubble = page.locator('[role="status"]'); const bubbleText = await bubble.innerText(); expect(bubbleText.length).toBeGreaterThan(10); await snap(page, '04-chat-error-graceful'); } }); }); // ═══════════════════════════════════════════════════════════════════════════ // 5. MODE TRANSITIONS // ═══════════════════════════════════════════════════════════════════════════ test.describe('5 — Mode Transitions', () => { test('5.1 tips → search → tips cycle works', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); // Verify tips mode (counter visible) const counter = page.locator('text=/\\d+ \\/ \\d+/'); await expect(counter).toBeVisible({ timeout: 3000 }); // Switch to search const input = page.getByPlaceholder(/search|ask stella/i); await input.fill('policy'); await input.press('Enter'); await page.waitForTimeout(1000); // Switch back to tips const backBtn = page.locator('button').filter({ hasText: /tips/i }).first(); if (await backBtn.isVisible({ timeout: 2000 }).catch(() => false)) { await backBtn.click(); await page.waitForTimeout(500); await expect(counter).toBeVisible({ timeout: 3000 }); } }); test('5.2 navigating to new page resets to tips mode', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); // Enter search mode const input = page.getByPlaceholder(/search|ask stella/i); await input.fill('test'); await input.press('Enter'); await page.waitForTimeout(500); // Navigate to different page await go(page, '/security'); await mascot.click(); await page.waitForTimeout(500); // Should be back in tips mode with security-related content const tipText = await page.locator('[role="status"]').innerText(); const securityTerms = ['security', 'posture', 'scan', 'vulnerability', 'risk']; const hasSecTerm = securityTerms.some(t => tipText.toLowerCase().includes(t)); expect(hasSecTerm).toBe(true); }); }); // ═══════════════════════════════════════════════════════════════════════════ // 6. DISMISS / RESTORE // ═══════════════════════════════════════════════════════════════════════════ test.describe('6 — Dismiss & Restore', () => { test('6.1 "Don\'t show again" hides the mascot', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); const dismissBtn = page.getByRole('button', { name: /don.t show again/i }); await expect(dismissBtn).toBeVisible(); await dismissBtn.click(); await page.waitForTimeout(500); // Full mascot should be hidden await expect(mascot).not.toBeVisible({ timeout: 2000 }); await snap(page, '06-dismissed'); }); test('6.2 restore button brings mascot back after dismiss', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); // Dismiss await page.getByRole('button', { name: /don.t show again/i }).click(); await page.waitForTimeout(500); // Look for restore button (small icon with "?" badge) const restoreBtn = page.getByRole('button', { name: /show stella|bring back/i }); await expect(restoreBtn).toBeVisible({ timeout: 3000 }); await restoreBtn.click(); await page.waitForTimeout(1000); // Mascot should be back await expect(page.getByRole('button', { name: /stella helper/i })).toBeVisible(); await snap(page, '06-restored'); }); test('6.3 dismiss state persists across page reload', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); await page.getByRole('button', { name: /don.t show again/i }).click(); await page.waitForTimeout(500); // Reload page await page.reload({ waitUntil: 'networkidle' }); await page.waitForTimeout(2000); // Mascot should still be dismissed (restore button visible instead) await expect(mascot).not.toBeVisible({ timeout: 3000 }); const restoreBtn = page.getByRole('button', { name: /show stella|bring back/i }); await expect(restoreBtn).toBeVisible(); }); }); // ═══════════════════════════════════════════════════════════════════════════ // 7. PERSISTENCE ACROSS NAVIGATION // ═══════════════════════════════════════════════════════════════════════════ test.describe('7 — Persistence', () => { test('7.1 tip position is remembered per page', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); // Advance to tip 3 const nextBtn = page.getByRole('button', { name: /next tip/i }); await nextBtn.click(); await page.waitForTimeout(200); await nextBtn.click(); await page.waitForTimeout(200); const counter = page.locator('text=/\\d+ \\/ \\d+/'); const pos = await counter.innerText(); expect(pos).toMatch(/3 \/ \d+/); // Navigate away and back await go(page, '/security'); await page.waitForTimeout(500); await go(page, '/'); await mascot.click(); await page.waitForTimeout(500); // Should return to tip 3 const restoredPos = await counter.innerText(); expect(restoredPos).toMatch(/3 \/ \d+/); }); test('7.2 first-visit auto-open only happens once per page', async ({ authenticatedPage: page }) => { await page.evaluate(() => { localStorage.removeItem('stellaops.assistant.state'); localStorage.removeItem('stellaops.helper.preferences'); }); await go(page, '/'); await page.waitForTimeout(2000); // First visit: bubble auto-opens let bubble = page.locator('[role="status"]'); const autoOpened = await bubble.isVisible({ timeout: 3000 }).catch(() => false); expect(autoOpened).toBe(true); // Close it const closeBtn = page.getByRole('button', { name: /close tip/i }); if (await closeBtn.isVisible({ timeout: 1000 }).catch(() => false)) { await closeBtn.click(); } // Navigate away and back await go(page, '/security'); await go(page, '/'); await page.waitForTimeout(2000); // Second visit to same page: should NOT auto-open bubble = page.locator('[role="status"]'); const autoOpenedAgain = await bubble.isVisible({ timeout: 2000 }).catch(() => false); expect(autoOpenedAgain).toBe(false); }); }); // ═══════════════════════════════════════════════════════════════════════════ // 8. CONTEXTUAL TIP RELEVANCE (per page/tab) // ═══════════════════════════════════════════════════════════════════════════ test.describe('8 — Contextual Relevance', () => { const pageExpectations: { route: string; name: string; expectedTerms: string[] }[] = [ { route: '/', name: 'Dashboard', expectedTerms: ['dashboard', 'overview', 'environment', 'sbom', 'feed'] }, { route: '/releases', name: 'Releases', expectedTerms: ['release', 'gate', 'evidence', 'promote', 'digest'] }, { route: '/releases/deployments', name: 'Deployments', expectedTerms: ['deployment', 'gate', 'approval', 'promote'] }, { route: '/security', name: 'Security Posture', expectedTerms: ['security', 'risk', 'posture', 'vex', 'sbom'] }, { route: '/security/reachability', name: 'Reachability', expectedTerms: ['reachability', 'call', 'static', 'runtime', 'code'] }, { route: '/ops/policy/vex', name: 'VEX', expectedTerms: ['vex', 'vulnerability', 'exploitability', 'statement', 'affected'] }, { route: '/ops/policy/governance', name: 'Governance', expectedTerms: ['budget', 'risk', 'threshold', 'policy'] }, { route: '/evidence/overview', name: 'Evidence', expectedTerms: ['evidence', 'signed', 'proof', 'audit', 'capsule'] }, { route: '/ops/operations', name: 'Operations', expectedTerms: ['operations', 'blocking', 'health', 'action'] }, { route: '/ops/operations/doctor', name: 'Diagnostics', expectedTerms: ['diagnostic', 'health', 'check', 'service'] }, { route: '/setup/integrations', name: 'Integrations', expectedTerms: ['integration', 'registry', 'connect', 'setup'] }, { route: '/setup/trust-signing', name: 'Certificates', expectedTerms: ['signing', 'key', 'certificate', 'trust', 'crypto'] }, ]; for (const { route, name, expectedTerms } of pageExpectations) { test(`8.x tips for ${name} (${route}) are relevant`, async ({ authenticatedPage: page }) => { await go(page, route); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); // Collect all tip text (cycle through all tips) let allTipText = ''; const counter = page.locator('text=/\\d+ \\/ \\d+/'); const nextBtn = page.getByRole('button', { name: /next tip/i }); const bubble = page.locator('[role="status"]'); allTipText += await bubble.innerText(); // Cycle through remaining tips for (let i = 0; i < 10; i++) { if (await nextBtn.isEnabled()) { await nextBtn.click(); await page.waitForTimeout(200); allTipText += ' ' + await bubble.innerText(); } else { break; } } // At least one expected term should appear in the combined tip text const lowerText = allTipText.toLowerCase(); const matchedTerms = expectedTerms.filter(t => lowerText.includes(t)); expect( matchedTerms.length, `Tips for ${name} should mention at least one of: [${expectedTerms.join(', ')}]. Got text: "${allTipText.slice(0, 200)}..."` ).toBeGreaterThan(0); }); } }); // ═══════════════════════════════════════════════════════════════════════════ // 9. SEND BUTTON & INPUT BEHAVIOR // ═══════════════════════════════════════════════════════════════════════════ test.describe('9 — Input Behavior', () => { test('9.1 send button is disabled when input is empty', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); const sendBtn = page.getByRole('button', { name: /send/i }); await expect(sendBtn).toBeDisabled(); }); test('9.2 send button enables when text is entered', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); const input = page.getByPlaceholder(/search|ask stella/i); await input.fill('test query'); const sendBtn = page.getByRole('button', { name: /send/i }); await expect(sendBtn).toBeEnabled(); }); test('9.3 Enter key submits the input', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); const input = page.getByPlaceholder(/search|ask stella/i); await input.fill('SBOM'); await input.press('Enter'); await page.waitForTimeout(1000); // Should have transitioned away from tips mode // (either search mode header or chat mode header visible) const modeIndicator = page.locator('text=/Search|Ask Stella/i').first(); await expect(modeIndicator).toBeVisible({ timeout: 3000 }); }); }); // ═══════════════════════════════════════════════════════════════════════════ // 10. ACCESSIBILITY // ═══════════════════════════════════════════════════════════════════════════ test.describe('10 — Accessibility', () => { test('10.1 mascot has accessible role and label', async ({ authenticatedPage: page }) => { await go(page, '/'); // The mascot container should have complementary role const assistant = page.getByRole('complementary', { name: /stella/i }); await expect(assistant).toBeVisible({ timeout: 5000 }); // The mascot button should have aria-label const mascot = page.getByRole('button', { name: /stella helper/i }); await expect(mascot).toBeVisible(); }); test('10.2 bubble uses aria-live for screen readers', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); // Content area should use role="status" (implicit aria-live="polite") const liveRegion = page.locator('[role="status"]'); await expect(liveRegion).toBeVisible(); }); test('10.3 close and navigation buttons have accessible labels', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); await mascot.click(); await page.waitForTimeout(500); await expect(page.getByRole('button', { name: /close tip/i })).toBeVisible(); await expect(page.getByRole('button', { name: /previous tip/i })).toBeVisible(); await expect(page.getByRole('button', { name: /next tip/i })).toBeVisible(); }); test('10.4 mascot button indicates expanded state', async ({ authenticatedPage: page }) => { await go(page, '/'); const mascot = page.getByRole('button', { name: /stella helper/i }); // Before clicking — not expanded await mascot.click(); await page.waitForTimeout(500); // After clicking — should have aria-expanded="true" const expanded = await mascot.getAttribute('aria-expanded'); expect(expanded).toBe('true'); }); }); // ═══════════════════════════════════════════════════════════════════════════ // 11. MULTIPLE PAGES SMOKE — No crashes // ═══════════════════════════════════════════════════════════════════════════ test.describe('11 — Smoke: Assistant survives rapid navigation', () => { test('11.1 rapid navigation does not crash the assistant', async ({ authenticatedPage: page }) => { const errors: string[] = []; page.on('console', (msg) => { if (msg.type() === 'error' && /NG0|TypeError|ReferenceError/.test(msg.text())) { errors.push(msg.text()); } }); const routes = [ '/', '/releases', '/releases/deployments', '/environments/overview', '/security', '/security/reachability', '/ops/policy/vex', '/evidence/overview', '/ops/operations', '/setup/integrations', '/ops/policy/governance', '/ops/operations/doctor', ]; for (const route of routes) { await page.goto(route, { waitUntil: 'domcontentloaded', timeout: 15_000 }); await page.waitForTimeout(800); } // Mascot should still be functional const mascot = page.getByRole('button', { name: /stella helper/i }); await expect(mascot).toBeVisible(); await mascot.click(); await page.waitForTimeout(500); const bubble = page.locator('[role="status"]'); await expect(bubble).toBeVisible(); // No critical Angular errors const criticalErrors = errors.filter(e => /NG0/.test(e)); expect(criticalErrors, `Angular errors: ${criticalErrors.join('\n')}`).toHaveLength(0); }); }); // ═══════════════════════════════════════════════════════════════════════════ // 12. ADMIN EDITOR (requires ui.admin scope) // ═══════════════════════════════════════════════════════════════════════════ test.describe('12 — Admin Editor', () => { test('12.1 admin page renders at /console-admin/assistant', async ({ authenticatedPage: page }) => { await go(page, '/console-admin/assistant'); const heading = page.locator('text=/Stella Assistant Editor/i'); await expect(heading).toBeVisible({ timeout: 10_000 }); await snap(page, '12-admin-editor'); }); test('12.2 admin page has tabs for Tips and Glossary', async ({ authenticatedPage: page }) => { await go(page, '/console-admin/assistant'); await page.waitForTimeout(1000); const tipsTab = page.getByRole('tab', { name: /tips/i }); const glossaryTab = page.getByRole('tab', { name: /glossary/i }); await expect(tipsTab).toBeVisible(); await expect(glossaryTab).toBeVisible(); }); test('12.3 new tip form has preview', async ({ authenticatedPage: page }) => { await go(page, '/console-admin/assistant'); await page.waitForTimeout(1000); const newTipTab = page.getByRole('tab', { name: /new tip/i }); await newTipTab.click(); await page.waitForTimeout(500); // Form fields should be visible const routeInput = page.getByPlaceholder(/\/ops\/policy/i); await expect(routeInput).toBeVisible(); // Preview section should exist const preview = page.locator('text=/preview/i').first(); await expect(preview).toBeVisible(); await snap(page, '12-admin-new-tip-form'); }); });