Files
waggle-os/tests/e2e/phase8-visual.spec.ts
Oleg Maslov b20b138fe4 moving
2026-09-02 10:14:22 +02:00

275 lines
12 KiB
TypeScript

/**
* Phase 8 structural smoke coverage for the current Waggle views.
*
* Deterministic pixel regression coverage lives in tests/visual/views.spec.ts.
*/
import { test, expect, type Page } from '@playwright/test';
const BASE = process.env.WAGGLE_E2E_BASE_URL ?? 'http://127.0.0.1:3333';
// ── 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);
}
// ═════════════════════════════════════════════════════════════════════════════
// 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);
});
});