Files
git.stella-ops.org/src/Web/StellaOps.Web/e2e/stella-assistant.e2e.spec.ts
master 8931fc7c0c Add unified Stella Assistant: mascot + search + AI chat as one
Merge three disconnected help surfaces (Stella mascot, Ctrl+K search,
Advisory AI chat) into one unified assistant. Mascot is the face,
search is its memory, AI chat is its voice.

Backend:
- DB schema (060/061): tips, greetings, glossary, tours, user_state
  tables with 189 tips + 101 greetings seed data
- REST API: GET tips/glossary/tours, GET/PUT user-state with
  longest-prefix route matching and locale fallback
- Admin endpoints: CRUD for tips, glossary, tours (SetupAdmin policy)

Frontend:
- StellaAssistantService: unified mode management (tips/search/chat),
  API-backed tips with static fallback, i18n integration
- Three-mode mascot component: tips, inline search, embedded chat
- StellaGlossaryDirective: DB-backed tooltip annotations for domain terms
- Admin tip editor: CRUD for tips/glossary/tours in Console Admin
- Tour player: step-through guided tours with element highlighting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:24:39 +03:00

800 lines
34 KiB
TypeScript

/**
* 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');
});
});