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>
800 lines
34 KiB
TypeScript
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');
|
|
});
|
|
});
|