424 lines
18 KiB
TypeScript
424 lines
18 KiB
TypeScript
/**
|
||
* Phase 8 — Visual Regression Baselines (9G-4)
|
||
*
|
||
* Captures screenshot baselines for all 7 Waggle views in both dark and light
|
||
* modes. This completes the 9G-4 gap identified in CONTINUE-PHASE9.md.
|
||
*
|
||
* Each view × theme = 1 baseline PNG. Total: 14 baselines.
|
||
*
|
||
* Baseline storage: tests/visual/baselines/
|
||
* Snapshot template: {snapshotDir}/{testName}/{arg}{ext} (from playwright.config.ts)
|
||
*
|
||
* Usage:
|
||
* # Create / update baselines (first run or after intentional UI changes)
|
||
* npx playwright test tests/e2e/phase8-visual.spec.ts --update-snapshots
|
||
*
|
||
* # Verify no regressions (CI)
|
||
* npx playwright test tests/e2e/phase8-visual.spec.ts
|
||
*
|
||
* Prerequisites:
|
||
* - Server running at localhost:3333 (playwright.config.ts webServer auto-starts it)
|
||
* - app/dist built (npm run build in app/)
|
||
* - No onboarding wizard state (fresh ~/.waggle or pre-seeded with config)
|
||
*
|
||
* Diff threshold: 0.3% pixel ratio (configured in playwright.config.ts)
|
||
*
|
||
* Notes:
|
||
* - Tests skip gracefully when onboarding wizard is active (first-run state).
|
||
* - MissionControl view is tested for presence only (may be gated by Phase 8D).
|
||
* - Animations are disabled via playwright config to prevent flaky snapshots.
|
||
*/
|
||
|
||
import { test, expect, type Page } from '@playwright/test';
|
||
|
||
const BASE = process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
|
||
const RUN_PIXEL_BASELINES = process.env.WAGGLE_E2E_VISUAL === '1' || !process.env.CI;
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
/** Wait for the Waggle app shell to be ready (copied from user-journeys.spec.ts). */
|
||
async function waitForApp(page: Page): Promise<void> {
|
||
await page.waitForSelector(
|
||
'.waggle-app-shell, .waggle-sidebar, [role="navigation"], [class*="onboarding"]',
|
||
{ timeout: 15_000 },
|
||
).catch(() => {});
|
||
await page.waitForTimeout(1000);
|
||
}
|
||
|
||
/** Returns true if the onboarding wizard is blocking the main UI. */
|
||
async function isOnboarding(page: Page): Promise<boolean> {
|
||
// OnboardingWizard renders with z-[9999] (not z-[1000])
|
||
const overlay = page.locator('.fixed.inset-0.z-\\[9999\\]');
|
||
if (await overlay.isVisible().catch(() => false)) return true;
|
||
const text = page.locator('text=Welcome to Waggle').or(page.locator('text=Why Waggle'));
|
||
return text.isVisible().catch(() => false);
|
||
}
|
||
|
||
/** Skip onboarding — hits the server API (source of truth) AND localStorage.
|
||
* The server persists onboardingCompleted in config.json which the app reads
|
||
* on every load — localStorage alone is not sufficient.
|
||
*/
|
||
async function skipOnboarding(page: Page): Promise<void> {
|
||
// 1. Server-side: PATCH /api/settings — this is what the app reads on load
|
||
await page.request.patch(`${BASE}/api/settings`, {
|
||
data: { onboardingCompleted: true },
|
||
headers: { 'Content-Type': 'application/json' },
|
||
}).catch(() => {}); // non-blocking — proceed even if server unreachable
|
||
|
||
// 2. addInitScript: fires before React mounts on next navigation
|
||
await page.addInitScript(() => {
|
||
localStorage.setItem('waggle:onboarding', JSON.stringify({ completed: true, step: 7 }));
|
||
localStorage.setItem('waggle:first-run', 'done');
|
||
});
|
||
|
||
// 3. Immediate evaluate: sets localStorage if page already loaded
|
||
await page.evaluate(() => {
|
||
try {
|
||
localStorage.setItem('waggle:onboarding', JSON.stringify({ completed: true, step: 7 }));
|
||
localStorage.setItem('waggle:first-run', 'done');
|
||
} catch { /* ignore */ }
|
||
}).catch(() => {});
|
||
}
|
||
|
||
function routeWithSkip(route: string): string {
|
||
const separator = route.includes('?') ? '&' : '?';
|
||
return `${route}${separator}skipOnboarding=true&skipBoot=true&tier=power&skipBriefing=true`;
|
||
}
|
||
|
||
/**
|
||
* Navigate to a named view via the sidebar button.
|
||
* Retries if the sidebar is collapsed.
|
||
*/
|
||
async function navigateTo(page: Page, viewName: string): Promise<void> {
|
||
// If onboarding overlay is visible, press Escape or click skip to dismiss it
|
||
const overlay = page.locator('.fixed.inset-0.z-\\[9999\\]');
|
||
if (await overlay.isVisible({ timeout: 500 }).catch(() => false)) {
|
||
// Try to find and click a skip/dismiss button
|
||
const skipBtn = page.locator('button').filter({ hasText: /skip|dismiss|close|later/i }).first();
|
||
if (await skipBtn.isVisible({ timeout: 500 }).catch(() => false)) {
|
||
await skipBtn.click().catch(() => {});
|
||
await page.waitForTimeout(500);
|
||
} else {
|
||
// Press Escape to dismiss
|
||
await page.keyboard.press('Escape');
|
||
await page.waitForTimeout(500);
|
||
}
|
||
}
|
||
|
||
const sidebar = page.locator('[role="navigation"]');
|
||
|
||
// Ensure sidebar is expanded
|
||
const expandBtn = page.locator('button[aria-label="Expand sidebar"]');
|
||
if (await expandBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||
await expandBtn.click();
|
||
await page.waitForTimeout(300);
|
||
}
|
||
|
||
const sidebarSelectors: Record<string, string[]> = {
|
||
Chat: ['[data-testid="nav-chat"]', 'button[aria-label="Chat"]'],
|
||
Memory: ['[data-testid="nav-memory"]', 'button[aria-label="Memory"]'],
|
||
Settings: ['[data-testid="sidebar-user"]', 'button[aria-label="Account and settings"]'],
|
||
'Agents': ['[data-testid="nav-agents"]', 'button[aria-label="Agents"]'],
|
||
Library: ['[data-testid="nav-library"]', 'button[aria-label="Library"]'],
|
||
};
|
||
|
||
for (const selector of sidebarSelectors[viewName] ?? []) {
|
||
const candidate = sidebar.locator(selector).first();
|
||
if (await candidate.isVisible({ timeout: 700 }).catch(() => false)) {
|
||
await candidate.click();
|
||
await page.waitForTimeout(600);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const btn = sidebar.locator('button', { hasText: viewName }).first();
|
||
if (await btn.isVisible({ timeout: 700 }).catch(() => false)) {
|
||
await btn.click();
|
||
await page.waitForTimeout(600);
|
||
return;
|
||
}
|
||
|
||
const routes: Record<string, string> = {
|
||
Chat: '/workspaces/default/chat',
|
||
Memory: '/memory',
|
||
Events: '/settings/events',
|
||
Capabilities: '/skills',
|
||
'Skills Hub': '/skills',
|
||
Cockpit: '/settings/mission-control',
|
||
'Mission Control': '/settings/mission-control',
|
||
Settings: '/settings',
|
||
};
|
||
const route = routes[viewName];
|
||
if (!route) throw new Error(`No current navigation target configured for "${viewName}"`);
|
||
await page.goto(routeWithSkip(route), { waitUntil: 'domcontentloaded' });
|
||
await waitForApp(page);
|
||
}
|
||
|
||
/**
|
||
* Set theme by clicking the sidebar theme toggle until the correct mode is active.
|
||
* Returns the final theme ('dark' | 'light').
|
||
*/
|
||
async function setTheme(page: Page, target: 'dark' | 'light'): Promise<void> {
|
||
// Theme toggle is in the sidebar — ensure it's expanded
|
||
await page.evaluate((mode) => {
|
||
localStorage.setItem('waggle-theme', mode);
|
||
if (mode === 'light') document.documentElement.setAttribute('data-theme', 'light');
|
||
else document.documentElement.removeAttribute('data-theme');
|
||
}, target);
|
||
await page.waitForTimeout(100);
|
||
}
|
||
|
||
/**
|
||
* Capture a stable screenshot — waits for network idle and hides dynamic elements
|
||
* (timestamps, cost counters, status bar tokens) that would cause diff failures.
|
||
*/
|
||
async function stableScreenshot(page: Page): Promise<Buffer> {
|
||
// Hide elements whose content changes between runs
|
||
await page.evaluate(() => {
|
||
const selectors = [
|
||
'[data-testid="status-bar-tokens"]',
|
||
'[data-testid="status-bar-cost"]',
|
||
'[class*="timestamp"]',
|
||
'[class*="Timestamp"]',
|
||
'.status-bar__cost',
|
||
'.waggle-status-bar__tokens',
|
||
];
|
||
for (const sel of selectors) {
|
||
document.querySelectorAll(sel).forEach((el) => {
|
||
(el as HTMLElement).style.visibility = 'hidden';
|
||
});
|
||
}
|
||
const dynamicText = [
|
||
/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s/i,
|
||
/^\d{1,2}:\d{2}$/,
|
||
/^Last active:/i,
|
||
];
|
||
document.querySelectorAll('body *').forEach((el) => {
|
||
if (el.children.length > 0) return;
|
||
const text = el.textContent?.trim() ?? '';
|
||
if (dynamicText.some((pattern) => pattern.test(text))) {
|
||
(el as HTMLElement).style.visibility = 'hidden';
|
||
}
|
||
});
|
||
document.querySelectorAll('button[aria-label="Notifications"]').forEach((el) => {
|
||
(el as HTMLElement).style.visibility = 'hidden';
|
||
});
|
||
});
|
||
|
||
await page.waitForTimeout(200);
|
||
|
||
return page.screenshot({ fullPage: false });
|
||
}
|
||
|
||
// ── View definitions ──────────────────────────────────────────────────────────
|
||
|
||
const VIEWS = [
|
||
{ name: 'Chat', sidebar: 'Chat' },
|
||
{ name: 'Memory', sidebar: 'Memory' },
|
||
{ name: 'Events', sidebar: 'Events' },
|
||
{ name: 'Capabilities', sidebar: 'Skills Hub' },
|
||
{ name: 'Cockpit', sidebar: 'Cockpit' },
|
||
{ name: 'MissionControl', sidebar: 'Mission Control' },
|
||
{ name: 'Settings', sidebar: 'Settings' },
|
||
] as const;
|
||
|
||
const THEMES = ['light', 'dark'] as const;
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
// Visual Baseline Tests (7 views × 2 themes = 14 baselines)
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
for (const theme of THEMES) {
|
||
test.describe(`Visual baselines — ${theme} mode`, () => {
|
||
test.skip(!RUN_PIXEL_BASELINES, 'Pixel baselines run with WAGGLE_E2E_VISUAL=1; structural smoke tests still run in CI.');
|
||
|
||
// Visual tests need more time: beforeEach (goto + waitForApp + setTheme) ~10-20s
|
||
// + navigateTo ~5s + waitForFunction + networkidle + screenshot ~10s = up to 35s
|
||
test.describe.configure({ timeout: 90_000 });
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
// CRITICAL: register addInitScript BEFORE first goto so localStorage
|
||
// is set BEFORE React mounts and reads onboarding state.
|
||
await page.addInitScript((targetTheme) => {
|
||
localStorage.setItem('waggle:onboarding', JSON.stringify({ completed: true, step: 7 }));
|
||
localStorage.setItem('waggle:first-run', 'done');
|
||
localStorage.setItem('waggle-theme', targetTheme);
|
||
if (targetTheme === 'light') document.documentElement.setAttribute('data-theme', 'light');
|
||
else document.documentElement.removeAttribute('data-theme');
|
||
}, theme);
|
||
// Server-side: PATCH /api/settings (belt and suspenders)
|
||
await page.request.patch(`${BASE}/api/settings`, {
|
||
data: { onboardingCompleted: true },
|
||
headers: { 'Content-Type': 'application/json' },
|
||
}).catch(() => {});
|
||
// NOW navigate — initScript fires before React, no onboarding shown
|
||
await page.goto(routeWithSkip('/home'));
|
||
await waitForApp(page);
|
||
await setTheme(page, theme);
|
||
});
|
||
|
||
for (const view of VIEWS) {
|
||
test(`${view.name} view — ${theme}`, async ({ page }) => {
|
||
// No skip conditions — if onboarding blocks navigation, test fails with clear error
|
||
// navigateTo will throw if sidebar button not found within 5s
|
||
await navigateTo(page, view.sidebar);
|
||
|
||
// Wait for view content — not a fixed timer
|
||
await page.waitForFunction(() =>
|
||
(document.body.textContent?.length ?? 0) > 100,
|
||
{ timeout: 8000 }
|
||
).catch(() => {});
|
||
await page.waitForTimeout(400); // short final settle for animations
|
||
|
||
const screenshot = await stableScreenshot(page);
|
||
expect(screenshot).toMatchSnapshot(`${view.name}-${theme}.png`);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
// Structural smoke tests — verify views render without crashing
|
||
// (These always run, even without baselines.)
|
||
// ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
test.describe('View structural smoke tests', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
// Register initScript BEFORE first goto — sets localStorage before React mounts
|
||
await page.addInitScript(() => {
|
||
localStorage.setItem('waggle:onboarding', JSON.stringify({ completed: true, step: 7 }));
|
||
localStorage.setItem('waggle:first-run', 'done');
|
||
});
|
||
await page.request.patch(`${BASE}/api/settings`, {
|
||
data: { onboardingCompleted: true },
|
||
headers: { 'Content-Type': 'application/json' },
|
||
}).catch(() => {});
|
||
await page.goto(routeWithSkip('/home'));
|
||
await waitForApp(page);
|
||
});
|
||
|
||
test('Chat view: textarea is present and accepts input', async ({ page }) => {
|
||
await skipOnboarding(page);
|
||
const hasOverlay = await page.locator('.fixed.inset-0.z-\\[9999\\]').isVisible().catch(() => false);
|
||
if (hasOverlay) { test.skip(true, 'Onboarding overlay still active'); return; }
|
||
|
||
await navigateTo(page, 'Chat');
|
||
const textarea = page.locator('textarea').first();
|
||
await expect(textarea).toBeVisible({ timeout: 5000 });
|
||
await textarea.fill('/help');
|
||
await expect(textarea).toHaveValue('/help');
|
||
});
|
||
|
||
test('Memory view: search input is present', async ({ page }) => {
|
||
await skipOnboarding(page);
|
||
const hasOverlay = await page.locator('.fixed.inset-0.z-\\[9999\\]').isVisible().catch(() => false);
|
||
if (hasOverlay) { test.skip(true, 'Onboarding overlay still active'); return; }
|
||
|
||
await navigateTo(page, 'Memory');
|
||
// Memory view has a search input or empty state
|
||
const searchOrEmpty = page.locator('[placeholder*="search" i]')
|
||
.or(page.locator('text=No memories'))
|
||
.or(page.locator('text=Search'));
|
||
await expect(searchOrEmpty.first()).toBeVisible({ timeout: 5000 });
|
||
});
|
||
|
||
test('Settings view: renders at least 5 tabs', async ({ page }) => {
|
||
await skipOnboarding(page);
|
||
const hasOverlay = await page.locator('.fixed.inset-0.z-\\[9999\\]').isVisible().catch(() => false);
|
||
if (hasOverlay) { test.skip(true, 'Onboarding overlay still active'); return; }
|
||
|
||
await navigateTo(page, 'Settings');
|
||
await page.waitForTimeout(500);
|
||
|
||
const tabs = page.locator('.settings-panel__tab');
|
||
const count = await tabs.count();
|
||
if (count > 0) {
|
||
expect(count).toBeGreaterThanOrEqual(5);
|
||
} else {
|
||
// May still be loading
|
||
const loading = page.locator('text=Loading').or(page.locator('text=General'));
|
||
await expect(loading.first()).toBeVisible({ timeout: 5000 });
|
||
}
|
||
});
|
||
|
||
test('Cockpit view: renders cards or loading skeletons', async ({ page }) => {
|
||
await skipOnboarding(page);
|
||
const hasOverlay = await page.locator('.fixed.inset-0.z-\\[9999\\]').isVisible().catch(() => false);
|
||
if (hasOverlay) { test.skip(true, 'Onboarding overlay still active'); return; }
|
||
|
||
await navigateTo(page, 'Cockpit');
|
||
await page.waitForTimeout(1500);
|
||
|
||
// Just verify the view loaded without crash — content varies
|
||
const body = await page.textContent('body') ?? '';
|
||
expect(body.length).toBeGreaterThan(50);
|
||
});
|
||
|
||
test('Capabilities view: renders marketplace or loading state', async ({ page }) => {
|
||
await skipOnboarding(page);
|
||
const hasOverlay = await page.locator('.fixed.inset-0.z-\\[9999\\]').isVisible().catch(() => false);
|
||
if (hasOverlay) { test.skip(true, 'Onboarding overlay still active'); return; }
|
||
|
||
await navigateTo(page, 'Skills Hub');
|
||
await page.waitForTimeout(1000);
|
||
|
||
const content = page.locator('text=Browse')
|
||
.or(page.locator('text=Installed'))
|
||
.or(page.locator('text=Marketplace'))
|
||
.or(page.locator('text=Loading'))
|
||
.or(page.locator('[class*="capability"]'));
|
||
await expect(content.first()).toBeVisible({ timeout: 5000 });
|
||
});
|
||
|
||
test('Events view: renders timeline or empty state', async ({ page }) => {
|
||
await skipOnboarding(page);
|
||
const hasOverlay = await page.locator('.fixed.inset-0.z-\\[9999\\]').isVisible().catch(() => false);
|
||
if (hasOverlay) { test.skip(true, 'Onboarding overlay still active'); return; }
|
||
|
||
await navigateTo(page, 'Events');
|
||
await page.waitForTimeout(1000);
|
||
|
||
// Just verify the view loaded without crash — content varies
|
||
const body = await page.textContent('body') ?? '';
|
||
expect(body.length).toBeGreaterThan(50);
|
||
});
|
||
|
||
test('Mission Control view: renders without crashing', async ({ page }) => {
|
||
await skipOnboarding(page);
|
||
const hasOverlay = await page.locator('.fixed.inset-0.z-\\[9999\\]').isVisible().catch(() => false);
|
||
if (hasOverlay) { test.skip(true, 'Onboarding overlay still active'); return; }
|
||
|
||
await navigateTo(page, 'Mission Control');
|
||
await page.waitForTimeout(500);
|
||
|
||
// Mission Control may be gated — just check it doesn't crash
|
||
await expect(page.locator('body')).not.toBeEmpty();
|
||
const bodyText = await page.textContent('body');
|
||
expect(bodyText?.trim().length).toBeGreaterThan(5);
|
||
});
|
||
|
||
test('theme toggle changes html class or data-theme attribute', async ({ page }) => {
|
||
await skipOnboarding(page);
|
||
const hasOverlay = await page.locator('.fixed.inset-0.z-\\[9999\\]').isVisible().catch(() => false);
|
||
if (hasOverlay) { test.skip(true, 'Onboarding overlay still active'); return; }
|
||
|
||
const getThemeSignal = async () => {
|
||
const cls = await page.locator('html').getAttribute('class') ?? '';
|
||
const dt = await page.locator('html').getAttribute('data-theme') ?? '';
|
||
return cls + dt;
|
||
};
|
||
|
||
await navigateTo(page, 'Settings');
|
||
await page.getByRole('tab', { name: /General/i }).click();
|
||
await expect(page.getByText('Theme')).toBeVisible({ timeout: 5000 });
|
||
const before = await getThemeSignal();
|
||
const target = before.includes('light') ? 'Dark' : 'Light';
|
||
|
||
await page.getByRole('button', { name: new RegExp(target, 'i') }).first().click();
|
||
await page.waitForTimeout(400);
|
||
|
||
const after = await getThemeSignal();
|
||
expect(after).not.toBe(before);
|
||
});
|
||
});
|